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
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
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! ✅
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! ✅
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! ✅
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
}
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)"
}
}
}
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)
}
}
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
}
}
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! 💪")
App của bạn đang dùng architecture gì? Chia sẻ kinh nghiệm! 💬
Tags
#Architecture #MVVM #CleanArchitecture #MobileDevelopment #iOS #Android #DesignPatterns #BestPractices