Mobile Dev

Mobile App Architecture: Clean Architecture & MVVM

Thiết kế kiến trúc ứng dụng mobile professional với Clean Architecture, MVVM pattern và best practices

11 phút đọc
NhiTuyen Tech Blog Team
Mobile App Architecture: Clean Architecture & MVVM

Mobile App Architecture: Xây Dựng App Bền Vững

Một app mobile tốt không chỉ có UI đẹp mà còn phải có kiến trúc tốt. Hãy cùng tìm hiểu Clean Architecture và MVVM - hai patterns phổ biến nhất trong mobile development!

Tại Sao Cần Architecture?

Vấn Đề Của “Spaghetti Code”

Chú thích: Spaghetti code = code lộn xộn, khó maintain, logic business trộn lẫn với UI.

// ❌ BAD - Tất cả logic trong ViewController
class ProductViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    
    var products: [Product] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Networking code trong ViewController? 😱
        let url = URL(string: "https://api.com/products")!
        URLSession.shared.dataTask(with: url) { data, _, error in
            // JSON parsing trong ViewController? 😱
            let products = try! JSONDecoder().decode([Product].self, from: data!)
            
            // Business logic trong ViewController? 😱
            self.products = products.filter { $0.price < 1000000 }
            
            DispatchQueue.main.async {
                self.tableView.reloadData()
            }
        }.resume()
    }
}

// Khó test, khó maintain, khó scale! 😢

Lợi Ích Của Architecture Tốt

  • 🧪 Testable: Dễ viết unit tests
  • 🔧 Maintainable: Dễ sửa bugs, thêm features
  • 📦 Modular: Tách biệt concerns
  • 👥 Scalable: Team lớn làm việc dễ dàng
  • 🔄 Reusable: Tái sử dụng code

Clean Code

MVVM Pattern

Model - View - ViewModel

Chú thích: MVVM tách UI (View) khỏi business logic (ViewModel) và data (Model).

┌─────────────┐
│    View     │  (UI - SwiftUI, Compose)
└──────┬──────┘
       │ Binding/Observe

┌─────────────┐
│  ViewModel  │  (Presentation Logic)
└──────┬──────┘
       │ Calls

┌─────────────┐
│   Model     │  (Business Logic, Data)
└─────────────┘

iOS - SwiftUI + MVVM

// Model - Data structure
struct Product: Identifiable, Codable {
    let id: Int
    let name: String
    let price: Double
    let imageUrl: String
}

// ViewModel - Presentation Logic
@MainActor
class ProductListViewModel: ObservableObject {
    // Published - View tự động update khi thay đổi
    @Published var products: [Product] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let repository: ProductRepository
    
    init(repository: ProductRepository = ProductRepository()) {
        self.repository = repository
    }
    
    func loadProducts() async {
        isLoading = true
        errorMessage = nil
        
        do {
            products = try await repository.fetchProducts()
        } catch {
            errorMessage = "Không thể tải sản phẩm: \(error.localizedDescription)"
        }
        
        isLoading = false
    }
    
    func filterExpensiveProducts() {
        products = products.filter { $0.price > 1000000 }
    }
}

// View - UI
struct ProductListView: View {
    @StateObject private var viewModel = ProductListViewModel()
    
    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("Đang tải...")
                } else if let error = viewModel.errorMessage {
                    Text(error)
                        .foregroundColor(.red)
                } else {
                    List(viewModel.products) { product in
                        ProductRow(product: product)
                    }
                }
            }
            .navigationTitle("Sản phẩm")
            .task {
                await viewModel.loadProducts()
            }
        }
    }
}

// Dễ test ViewModel mà không cần UI! ✅

MVVM iOS

Android - Compose + MVVM

// Model
data class Product(
    val id: Int,
    val name: String,
    val price: Double,
    val imageUrl: String
)

// ViewModel
class ProductListViewModel(
    private val repository: ProductRepository = ProductRepository()
) : ViewModel() {
    
    // StateFlow - Compose tự động recompose khi thay đổi
    private val _products = MutableStateFlow<List<Product>>(emptyList())
    val products: StateFlow<List<Product>> = _products.asStateFlow()
    
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    
    private val _errorMessage = MutableStateFlow<String?>(null)
    val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
    
    fun loadProducts() {
        viewModelScope.launch {
            _isLoading.value = true
            _errorMessage.value = null
            
            try {
                _products.value = repository.fetchProducts()
            } catch (e: Exception) {
                _errorMessage.value = "Không thể tải sản phẩm: ${e.message}"
            }
            
            _isLoading.value = false
        }
    }
    
    fun filterExpensiveProducts() {
        _products.value = _products.value.filter { it.price > 1000000 }
    }
}

// View - Composable
@Composable
fun ProductListScreen(
    viewModel: ProductListViewModel = viewModel()
) {
    val products by viewModel.products.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val errorMessage by viewModel.errorMessage.collectAsState()
    
    LaunchedEffect(Unit) {
        viewModel.loadProducts()
    }
    
    when {
        isLoading -> {
            CircularProgressIndicator()
        }
        errorMessage != null -> {
            Text(
                text = errorMessage!!,
                color = MaterialTheme.colorScheme.error
            )
        }
        else -> {
            LazyColumn {
                items(products) { product ->
                    ProductRow(product)
                }
            }
        }
    }
}

// Dễ test ViewModel! ✅

MVVM Android

Clean Architecture

Layers của Clean Architecture

Chú thích: Clean Architecture chia app thành các layers độc lập, mỗi layer có trách nhiệm riêng.

┌─────────────────────────────────────┐
│     Presentation Layer              │
│  (UI, ViewModel, Controllers)       │
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│       Domain Layer                  │
│  (Use Cases, Entities, Interfaces)  │  ← Business Logic
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│        Data Layer                   │
│  (Repositories, API, Database)      │
└─────────────────────────────────────┘

Domain Layer - Business Logic

// Entity - Core business object
struct User {
    let id: Int
    let email: String
    let name: String
    var isPremium: Bool
}

// Repository Protocol - Interface
// Chú thích: Protocol định nghĩa "contract", implementation ở Data Layer
protocol UserRepository {
    func getUser(id: Int) async throws -> User
    func updateUser(_ user: User) async throws
}

// Use Case - Business logic cụ thể
class GetUserUseCase {
    private let repository: UserRepository
    
    init(repository: UserRepository) {
        self.repository = repository
    }
    
    func execute(userId: Int) async throws -> User {
        let user = try await repository.getUser(id: userId)
        
        // Business rules ở đây
        if user.email.isEmpty {
            throw ValidationError.invalidEmail
        }
        
        return user
    }
}

// Domain layer không biết gì về API, Database! ✅

Data Layer - Implementation

// API Service
class UserAPIService {
    func fetchUser(id: Int) async throws -> UserDTO {
        let url = URL(string: "https://api.com/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(UserDTO.self, from: data)
    }
}

// DTO - Data Transfer Object
struct UserDTO: Codable {
    let id: Int
    let email: String
    let name: String
    let premium: Bool
}

// Repository Implementation
class UserRepositoryImpl: UserRepository {
    private let apiService: UserAPIService
    private let database: UserDatabase
    
    init(apiService: UserAPIService, database: UserDatabase) {
        self.apiService = apiService
        self.database = database
    }
    
    func getUser(id: Int) async throws -> User {
        // Try cache first
        if let cached = try? database.getUser(id: id) {
            return cached
        }
        
        // Fetch from API
        let dto = try await apiService.fetchUser(id: id)
        
        // Convert DTO to Domain Entity
        let user = User(
            id: dto.id,
            email: dto.email,
            name: dto.name,
            isPremium: dto.premium
        )
        
        // Cache it
        try database.save(user: user)
        
        return user
    }
    
    func updateUser(_ user: User) async throws {
        // Implementation
    }
}

// Data layer handle API, Database, Cache! ✅

Clean Architecture

Presentation Layer - UI

// ViewModel sử dụng Use Case
@MainActor
class UserProfileViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let getUserUseCase: GetUserUseCase
    
    init(getUserUseCase: GetUserUseCase) {
        self.getUserUseCase = getUserUseCase
    }
    
    func loadUser(id: Int) async {
        isLoading = true
        errorMessage = nil
        
        do {
            user = try await getUserUseCase.execute(userId: id)
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
}

// View
struct UserProfileView: View {
    @StateObject private var viewModel: UserProfileViewModel
    let userId: Int
    
    init(userId: Int) {
        self.userId = userId
        
        // Dependency Injection
        let repository = UserRepositoryImpl(
            apiService: UserAPIService(),
            database: UserDatabase()
        )
        let useCase = GetUserUseCase(repository: repository)
        
        _viewModel = StateObject(
            wrappedValue: UserProfileViewModel(getUserUseCase: useCase)
        )
    }
    
    var body: some View {
        // UI code
    }
}

// Mỗi layer độc lập, dễ test! ✅

Dependency Injection

Chú thích: DI giúp inject dependencies từ bên ngoài, dễ test và swap implementations.

iOS - Swinject

import Swinject

// Container setup
let container = Container()

// Register dependencies
container.register(UserAPIService.self) { _ in
    UserAPIService()
}

container.register(UserDatabase.self) { _ in
    UserDatabase()
}

container.register(UserRepository.self) { resolver in
    UserRepositoryImpl(
        apiService: resolver.resolve(UserAPIService.self)!,
        database: resolver.resolve(UserDatabase.self)!
    )
}

container.register(GetUserUseCase.self) { resolver in
    GetUserUseCase(
        repository: resolver.resolve(UserRepository.self)!
    )
}

// Resolve
let useCase = container.resolve(GetUserUseCase.self)!

Android - Koin

import org.koin.dsl.module

// Koin modules
val dataModule = module {
    single { UserAPIService() }
    single { UserDatabase(get()) }
    single<UserRepository> { 
        UserRepositoryImpl(get(), get()) 
    }
}

val domainModule = module {
    factory { GetUserUseCase(get()) }
}

val presentationModule = module {
    viewModel { UserProfileViewModel(get()) }
}

// App startup
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        
        startKoin {
            androidContext(this@MyApplication)
            modules(dataModule, domainModule, presentationModule)
        }
    }
}

// Usage in Composable
@Composable
fun UserProfileScreen() {
    val viewModel: UserProfileViewModel = koinViewModel()
    // Use viewModel
}

Dependency Injection

Repository Pattern

Chú thích: Repository là single source of truth cho data, abstract việc lấy data từ đâu (API, DB, Cache).

protocol ProductRepository {
    func getProducts() async throws -> [Product]
    func getProduct(id: Int) async throws -> Product
    func searchProducts(query: String) async throws -> [Product]
}

class ProductRepositoryImpl: ProductRepository {
    private let apiService: APIService
    private let cache: CacheService
    private let database: DatabaseService
    
    func getProducts() async throws -> [Product] {
        // Strategy: Cache-First
        if let cached = cache.getProducts(), !cached.isEmpty {
            return cached
        }
        
        // Fallback to API
        let products = try await apiService.fetchProducts()
        cache.save(products: products)
        
        return products
    }
    
    func getProduct(id: Int) async throws -> Product {
        // Strategy: Database-First, then API
        if let local = try? database.getProduct(id: id) {
            return local
        }
        
        let product = try await apiService.fetchProduct(id: id)
        try database.save(product: product)
        
        return product
    }
    
    func searchProducts(query: String) async throws -> [Product] {
        // Always fresh from API
        return try await apiService.searchProducts(query: query)
    }
}

// ViewModel chỉ cần biết Repository, không cần biết API/DB! ✅

Error Handling

// Custom Error Types
enum NetworkError: Error {
    case noInternet
    case serverError(Int)
    case invalidResponse
    case unauthorized
}

enum ValidationError: Error {
    case invalidEmail
    case passwordTooShort
    case requiredFieldEmpty(String)
}

// Repository handles network errors
class ProductRepositoryImpl: ProductRepository {
    func getProducts() async throws -> [Product] {
        guard NetworkMonitor.isConnected else {
            throw NetworkError.noInternet
        }
        
        do {
            return try await apiService.fetchProducts()
        } catch let urlError as URLError {
            throw NetworkError.noInternet
        } catch {
            throw NetworkError.invalidResponse
        }
    }
}

// Use Case handles business errors
class GetProductsUseCase {
    func execute() async throws -> [Product] {
        let products = try await repository.getProducts()
        
        // Business validation
        guard !products.isEmpty else {
            throw ValidationError.requiredFieldEmpty("products")
        }
        
        return products
    }
}

// ViewModel presents errors to user
@MainActor
class ProductListViewModel: ObservableObject {
    @Published var errorMessage: String?
    
    func loadProducts() async {
        do {
            products = try await useCase.execute()
        } catch NetworkError.noInternet {
            errorMessage = "Không có kết nối Internet"
        } catch NetworkError.serverError(let code) {
            errorMessage = "Lỗi server: \(code)"
        } catch {
            errorMessage = "Đã xảy ra lỗi: \(error.localizedDescription)"
        }
    }
}

Error Handling

Testing

Unit Testing Use Cases

import XCTest

class GetUserUseCaseTests: XCTestCase {
    var sut: GetUserUseCase!
    var mockRepository: MockUserRepository!
    
    override func setUp() {
        super.setUp()
        mockRepository = MockUserRepository()
        sut = GetUserUseCase(repository: mockRepository)
    }
    
    func testExecute_ValidUser_ReturnsUser() async throws {
        // Given
        let expectedUser = User(id: 1, email: "test@test.com", name: "Test")
        mockRepository.userToReturn = expectedUser
        
        // When
        let user = try await sut.execute(userId: 1)
        
        // Then
        XCTAssertEqual(user.id, expectedUser.id)
        XCTAssertEqual(user.email, expectedUser.email)
    }
    
    func testExecute_InvalidEmail_ThrowsError() async {
        // Given
        let invalidUser = User(id: 1, email: "", name: "Test")
        mockRepository.userToReturn = invalidUser
        
        // When/Then
        do {
            _ = try await sut.execute(userId: 1)
            XCTFail("Should throw error")
        } catch {
            XCTAssertTrue(error is ValidationError)
        }
    }
}

// Mock Repository
class MockUserRepository: UserRepository {
    var userToReturn: User?
    var shouldThrowError = false
    
    func getUser(id: Int) async throws -> User {
        if shouldThrowError {
            throw NetworkError.serverError(500)
        }
        return userToReturn!
    }
    
    func updateUser(_ user: User) async throws {
        // Mock implementation
    }
}

// Easy to test! ✅

Testing ViewModel

class UserProfileViewModelTests: XCTestCase {
    var sut: UserProfileViewModel!
    var mockUseCase: MockGetUserUseCase!
    
    @MainActor
    override func setUp() {
        super.setUp()
        mockUseCase = MockGetUserUseCase()
        sut = UserProfileViewModel(getUserUseCase: mockUseCase)
    }
    
    @MainActor
    func testLoadUser_Success_UpdatesUser() async {
        // Given
        let expectedUser = User(id: 1, email: "test@test.com", name: "Test")
        mockUseCase.userToReturn = expectedUser
        
        // When
        await sut.loadUser(id: 1)
        
        // Then
        XCTAssertEqual(sut.user?.id, expectedUser.id)
        XCTAssertNil(sut.errorMessage)
        XCTAssertFalse(sut.isLoading)
    }
    
    @MainActor
    func testLoadUser_Error_ShowsErrorMessage() async {
        // Given
        mockUseCase.shouldThrowError = true
        
        // When
        await sut.loadUser(id: 1)
        
        // Then
        XCTAssertNil(sut.user)
        XCTAssertNotNil(sut.errorMessage)
        XCTAssertFalse(sut.isLoading)
    }
}

Testing

Best Practices

1. Single Responsibility

Chú thích: Mỗi class chỉ làm một việc duy nhất.

// ❌ BAD - ViewController làm quá nhiều việc
class ProductViewController {
    func viewDidLoad() {
        fetchProducts()
        parseJSON()
        updateUI()
        handleErrors()
        // Quá nhiều responsibilities!
    }
}

// ✅ GOOD - Tách ra các classes
class ProductViewModel {
    func loadProducts()  // Presentation logic only
}

class ProductRepository {
    func fetchProducts()  // Data fetching only
}

class ProductParser {
    func parse(data: Data)  // Parsing only
}

2. Dependency Inversion

// ❌ BAD - High-level phụ thuộc vào low-level
class ProductViewModel {
    let apiService = APIService()  // Concrete class
    
    func loadProducts() {
        apiService.fetchProducts()  // Tightly coupled!
    }
}

// ✅ GOOD - Depend on abstraction
protocol ProductDataSource {
    func fetchProducts() async throws -> [Product]
}

class ProductViewModel {
    let dataSource: ProductDataSource  // Protocol
    
    init(dataSource: ProductDataSource) {
        self.dataSource = dataSource
    }
    
    func loadProducts() async {
        let products = try await dataSource.fetchProducts()
    }
}

// Có thể swap implementation dễ dàng!

3. Keep ViewModels Dumb

// ViewModel chỉ handle presentation logic
class ProductListViewModel {
    // ❌ BAD - Business logic trong ViewModel
    func loadProducts() {
        let products = await repository.getProducts()
        let filtered = products.filter { $0.price > 100 }
        let sorted = filtered.sorted { $0.name < $1.name }
        // Complex business logic!
    }
    
    // ✅ GOOD - Delegate to Use Case
    func loadProducts() {
        let products = try await getProductsUseCase.execute()
        // Just present the data
        self.products = products
    }
}

Best Practices

Folder Structure

iOS Project

MyApp/
├── Presentation/
│   ├── Screens/
│   │   ├── ProductList/
│   │   │   ├── ProductListView.swift
│   │   │   ├── ProductListViewModel.swift
│   │   │   └── Components/
│   │   └── ProductDetail/
│   └── Common/
│       └── Views/
├── Domain/
│   ├── Entities/
│   │   ├── Product.swift
│   │   └── User.swift
│   ├── UseCases/
│   │   ├── GetProductsUseCase.swift
│   │   └── GetUserUseCase.swift
│   └── Repositories/
│       └── ProductRepository.swift (Protocol)
├── Data/
│   ├── Repositories/
│   │   └── ProductRepositoryImpl.swift
│   ├── Network/
│   │   ├── APIService.swift
│   │   └── DTOs/
│   ├── Database/
│   │   └── CoreDataManager.swift
│   └── Cache/
│       └── CacheService.swift
└── Core/
    ├── DI/
    │   └── DIContainer.swift
    ├── Extensions/
    └── Utils/

Kết Luận

Clean Architecture + MVVM giúp bạn:

  • ✅ Code dễ test
  • ✅ Dễ maintain và scale
  • ✅ Team collaboration tốt hơn
  • ✅ Bugs ít hơn
  • ✅ Professional hơn
// Your mobile app architecture journey
let journey = [
    "Beginner": "Spaghetti code",
    "Intermediate": "MVC/MVP",
    "Advanced": "MVVM + Clean Architecture",
    "Expert": "Customized architecture cho project! 🚀"
]

print("Start building better apps today! 💪")

Success


App của bạn đang dùng architecture gì? Chia sẻ kinh nghiệm! 💬

Tags

#Architecture #MVVM #CleanArchitecture #MobileDevelopment #iOS #Android #DesignPatterns #BestPractices

Tags:

#Architecture #MVVM #Clean Architecture #Mobile #iOS #Android #Design Pattern

Chia sẻ bài viết:

Bài viết liên quan

Bài viết liên quan 1
Bài viết liên quan 2
Bài viết liên quan 3