Clean Architecture — một kiến trúc đa lớp được Robert Martin (Uncle Bob) đề xuất vào năm 2012, chia ứng dụng thành các lớp độc lập: Domain (Entities, Use Cases), Data (Repositories, DataSources) và Presentation (ViewModels, Views). Nguyên tắc chính là Dependency Rule: các phụ thuộc hướng vào trong, các lớp bên ngoài phụ thuộc vào các lớp bên trong, nhưng không ngược lại. Clean Architecture được sử dụng trong phát triển di động cho các dự án có độ phức tạp logic kinh doanh cao. Chi tiết hơn trong cuốn sách The Clean Architecture.
Những Điểm Chính
Clean Architecture — một mẫu kiến trúc được Robert Martin (Uncle Bob) xây dựng vào năm 2012. Ý tưởng cốt lõi là chia ứng dụng thành các lớp với quy tắc phụ thuộc nghiêm ngặt: mã bên trong một lớp không biết gì về mã bên ngoài. Các lớp bên ngoài (UI, framework, DB) là chi tiết triển khai. Các lớp bên trong (logic kinh doanh, quy tắc doanh nghiệp) là bản chất của ứng dụng.
Các lớp Clean Architecture trong phát triển di động: 1) Domain — Entities (đối tượng kinh doanh) và Use Cases (kịch bản sử dụng); 2) Data — RepositoryImpl (triển khai kho lưu trữ), DataSources (mạng, DB, bộ nhớ đệm); 3) Presentation — ViewModels, Views (Compose/SwiftUI). Domain là lớp trong cùng không có phụ thuộc. Data phụ thuộc vào Domain (triển khai các giao diện kho lưu trữ). Presentation phụ thuộc vào Domain (gọi Use Cases, đăng ký kết quả).
| Lớp | Chứa | Phụ thuộc |
|---|---|---|
| Domain | Entities, Use Cases, Repository Interfaces | Không (Kotlin/Swift thuần túy) |
| Data | RepositoryImpl, DataSources (API, DB, Bộ nhớ đệm) | Domain, Retrofit, Room, Ktor |
| Presentation | ViewModels, Views, Composables | Domain, Jetpack, SwiftUI |
Dependency Rule — quy tắc nghiêm ngặt duy nhất của Clean Architecture. Mã nguồn chỉ có thể tham chiếu đến một lớp bên trong chính nó hoặc một lớp bên dưới (gần trung tâm hơn). Presentation import Domain. Domain KHÔNG import Data hoặc Presentation. Điều này đạt được thông qua Nguyên tắc Đảo ngược Phụ thuộc: Domain định nghĩa giao diện Repository, Data triển khai nó. Presentation phụ thuộc vào sự trừu tượng hóa UseCase, không phải vào một kho lưu trữ cụ thể.
Domain — lớp ổn định nhất của ứng dụng. Entities là các đối tượng kinh doanh độc lập với framework: User, Product, Order. Use Cases là các lớp có một phương thức invoke duy nhất (hoặc operator fun invoke trong Kotlin), triển khai một kịch bản: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. Repository Interfaces là các trừu tượng hóa truy cập dữ liệu được định nghĩa trong Domain, triển khai trong Data. Domain không chứa Android SDK, iOS UIKit, Retrofit, Room — chỉ Kotlin hoặc Swift thuần túy.
// Entity — đối tượng kinh doanh (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — trừu tượng hóa dữ liệu (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — một kịch bản (Domain)
final class GetUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: Int) async throws -> User {
return try await repository.getUser(id: id)
}
}
Use Case — «một lớp với một phương thức» không phải là giáo điều mà là một khuyến nghị thực tế. Khi một Use Case trở nên phức tạp hơn (xác thực + ghi nhật ký + gọi kho lưu trữ), các phương thức của nó được nhóm theo ý nghĩa: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. Điều quan trọng là Use Case không nên biết dữ liệu đến từ đâu (mạng, DB, bộ nhớ đệm) hoặc ai hiển thị nó (Compose, SwiftUI). Tại IT Sectr, chúng tôi phân bổ một Use Case cho mỗi thao tác có quy tắc kinh doanh, xác thực hoặc kết hợp dữ liệu từ hai nguồn.
Tính thuần khiết của Domain đạt được thông qua ánh xạ DTO tại ranh giới các lớp. Lớp Data nhận các mô hình JSON (DTO), ánh xạ chúng thành Domain Entity. Lớp Presentation nhận Domain Entity, ánh xạ thành ViewModel (DisplayItem). Một Domain Entity không bao giờ chứa chú thích Retrofit, Room hoặc Codable — điều này đảm bảo lớp sẽ không cần thay đổi khi chuyển DB từ Room sang Realm hoặc thay thế Retrofit bằng Ktor.
Lớp Data — triển khai các giao diện được định nghĩa trong Domain. Chứa RepositoryImpl (các lớp triển khai UserRepository) và DataSources (RemoteDataSource — API, LocalDataSource — DB, CacheDataSource — SharedPreferences/NSUserDefaults). Lớp Data phụ thuộc vào Domain (import các giao diện kho lưu trữ và Entities) và các framework (Retrofit, Room, Ktor, CoreData). RepositoryImpl ẩn nguồn dữ liệu khỏi Domain — Use Case không biết dữ liệu đến từ mạng hay bộ nhớ đệm.
// DTO — mô hình cho mạng (Data)
data class UserDto(
@SerializedName("id") val id: Int,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
@SerializedName("email") val email: String
)
// RepositoryImpl — triển khai (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// Đang thử lấy từ bộ nhớ đệm
localDataSource.getUser(id)?.let { return it.toDomain() }
// Nếu không — tải từ mạng
val dto = remoteDataSource.fetchUser(id)
val user = dto.toDomain()
localDataSource.saveUser(user)
return user
}
override suspend fun getUsers(): List<User> {
return remoteDataSource.fetchAllUsers().map { it.toDomain() }
}
}
// Mapper — chuyển đổi DTO ↔ Domain
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Chiến lược bộ nhớ đệm trong Lớp Data: RepositoryImpl trước tiên kiểm tra bộ nhớ cục bộ; nếu không tìm thấy dữ liệu, tải từ mạng và lưu cục bộ. Nếu mạng không khả dụng, trả về dữ liệu cũ với cờ isStale. Use Case trong Domain không biết về chiến lược — nó nhận User qua Repository.getUser(id). Thay đổi chiến lược (ví dụ: vô hiệu hóa bộ nhớ đệm mỗi 15 phút) không ảnh hưởng đến Domain và Presentation.
Tính mô-đun trong Android — Kotlin Multiplatform cho phép tách Domain thành một mô-đun KMP riêng biệt mà không có phụ thuộc Android SDK. Data là một mô-đun riêng biệt phụ thuộc vào Domain. Presentation là một mô-đun Android phụ thuộc vào Domain. Phụ thuộc Gradle: domain (Kotlin thuần túy), data (domain + Retrofit + Room), app (domain + presentation + Hilt). Tính mô-đun như vậy rất cần thiết cho các dự án lớn — CI xây dựng Domain riêng biệt, các bài kiểm thử đơn vị Domain không yêu cầu trình giả lập Android.
Lớp Presentation — lớp ngoài cùng của Clean Architecture. Chứa ViewModels (Android) / ObservableObject (iOS) và Views (Compose/SwiftUI). ViewModel gọi một Use Case, nhận kết quả và chuyển đổi thành trạng thái UI. View đăng ký trạng thái và hiển thị. Presentation phụ thuộc vào Domain — import Use Cases và Entities. Presentation không import Lớp Data — dữ liệu đến qua Use Case, nội bộ sử dụng Repository.
ViewModel trong Clean Architecture không chứa logic kinh doanh — nó gọi Use Case. Nếu Use Case trả về User, ViewModel chuyển đổi nó thành UserDisplayItem (name, emailFormatted, avatarUrl) — một mô hình trình bày thuần túy. Use Case không biết về DisplayItem — nó trả về Entity. Sự phân tách này cho phép kiểm thử Use Case mà không cần UI và ViewModel mà không cần UseCase (thông qua mock). Tại IT Sectr, chúng tôi tuân thủ nghiêm ngặt: Use Case — logic kinh doanh, ViewModel — chỉ trình bày, View — chỉ hiển thị.
// Use Case (Domain) — logic kinh doanh thuần túy
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — chỉ trình bày
class UserViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
val state: StateFlow<UserScreenState> = _state.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_state.value = UserScreenState.Loading
val user = getUserUseCase(id)
val displayItem = UserDisplayItem(
name = user.name,
email = user.email,
initials = user.name.split(" ").joinToString("") { it.first().toString() }
)
_state.value = UserScreenState.Success(displayItem)
}
}
}
data class UserDisplayItem(
val name: String,
val email: String,
val initials: String
)
sealed interface UserScreenState {
data object Loading : UserScreenState
data class Success(val displayItem: UserDisplayItem) : UserScreenState
data class Error(val message: String) : UserScreenState
}
Điều hướng trong lớp Presentation cũng là một phần của vòng ngoài. Clean Architecture không quy định cơ chế điều hướng — nó có thể là NavController (Compose), NavigationStack (SwiftUI), Coordinator (UIKit) hoặc Router (VIPER). Điều quan trọng là quyết định điều hướng do Presentation đưa ra, nhưng điều hướng không nên xâm nhập vào Use Case. Use Case trả về kết quả, ViewModel quyết định chuyển đến màn hình nào. Trong Clean Architecture, điều hướng là một chi tiết có thể thay thế mà không thay đổi Domain.
Clean Architecture trên Android được triển khai qua các mô-đun Gradle: domain (Kotlin thuần túy), data (domain + Retrofit + Room), presentation (domain + Compose). Cấu trúc thư mục: domain/user/User.kt, GetUserUseCase.kt, UserRepository.kt; data/remote/UserRemoteDataSource.kt, local/UserDao.kt, repository/UserRepositoryImpl.kt; presentation/ui/user/UserViewModel.kt, UserScreen.kt. DI (Hilt) kết nối các lớp: UserRepositoryImpl được liên kết với giao diện UserRepository trong mô-đun domain.
Clean Architecture trên iOS sử dụng SPM hoặc nhóm Xcode mà không có mô-đun riêng biệt (do hạn chế của Xcode). Domain — một thư mục chứa các tệp không import UIKit hoặc SwiftUI. Data — một thư mục chứa APIClient, CoreDataStack, RepositoryImpl. Presentation — một thư mục chứa ViewModels và SwiftUI Views. DI qua hàm tạo hoặc lắp ráp trong ứng dụng. Lệnh gọi chính là async/await qua UseCase.execute() với kiểm tra MainActor để cập nhật UI.
// Data Layer: Remote DataSource (iOS)
final class UserRemoteDataSource {
private let apiClient: APIClient
func fetchUser(id: Int) async throws -> UserDTO {
return try await apiClient.get("/users/\(id)")
}
}
// Repository Implementation (Data)
final class UserRepositoryImpl: UserRepository {
private let remote: UserRemoteDataSource
private let local: UserLocalDataSource
func getUser(id: Int) async throws -> User {
if let cached = try await local.getUser(id) {
return cached
}
let dto = try await remote.fetchUser(id)
let user = dto.toDomain()
try await local.saveUser(user)
return user
}
}
// Presentation: ViewModel + SwiftUI View
@MainActor
final class UserViewModel: ObservableObject {
@Published private(set) var state: UserScreenState = .loading
private let getUserUseCase: GetUserUseCase
func loadUser(id: Int) {
Task {
state = .loading
if let user = try? await getUserUseCase.execute(id: id) {
state = .success(user)
} else {
state = .error("Failed to load")
}
}
}
}
Clean Architecture trong các dự án IT Sectr — tiêu chuẩn của chúng tôi cho các dự án từ 30 ngày trở lên. Chúng tôi đã sử dụng kiến trúc ba lớp với Kotlin Multiplatform cho Android/iOS từ năm 2022. Domain — mô-đun KMP dùng chung, Data — mô-đun nền tảng (Retrofit trên Android, URLSession trên iOS), Presentation — UI gốc. Điều này mang lại 60–80% mã logic kinh doanh dùng chung giữa iOS và Android, giảm thời gian phát triển 30–40% so với hai triển khai riêng biệt.
Các câu hỏi thường gặp
Tối thiểu ba: Domain, Data, Presentation. Đối với các dự án lớn, thêm Framework (phụ thuộc Android SDK/iOS UIKit) và Device (GPS, camera, cảm biến). Số lượng lớp không phải là quy tắc cứng nhắc mà là vấn đề thuận tiện. Điều quan trọng là tuân theo Dependency Rule: các phụ thuộc hướng vào trong, về phía Domain. Bạn có thể bắt đầu với ba và thêm các lớp khi dự án phát triển.
Có — tăng 30–50% so với MVVM do việc tách các giao diện kho lưu trữ, Use Cases và bộ ánh xạ. Điều này là quá mức đối với một ứng dụng CRUD đơn giản. Clean Architecture phù hợp cho các dự án có logic kinh doanh phức tạp, nơi khả năng kiểm thử và cách ly lớp quan trọng hơn tốc độ phát triển. Đối với MVP hoặc nguyên mẫu, hãy sử dụng MVVM — Clean Architecture sẽ làm chậm quá trình khởi động.
Có, đây là thực tế phổ biến. Các Use Cases vẫn nằm trong Domain, trong khi Presentation sử dụng chu trình MVI (Intent → Reducer → State). Lớp Data giữ nguyên, Domain giữ nguyên. MVI trong Presentation cung cấp trạng thái màn hình có thể dự đoán, Clean Architecture cung cấp sự cách ly logic kinh doanh. Sự kết hợp này được sử dụng trong các dự án lớn với hàng chục nhà phát triển.
Use Case cần thiết khi thao tác liên quan đến quy tắc kinh doanh: xác thực, kết hợp dữ liệu từ hai nguồn, tính toán, ghi nhật ký, kiểm tra quyền truy cập. Một yêu cầu getUser(id) đơn giản không có logic bổ sung có thể gọi Repository trực tiếp từ ViewModel. Tuy nhiên, để nhất quán kiến trúc, nhiều nhóm tạo Use Case cho mọi phương thức công khai của Repository — điều này thêm 5–10% mã nhưng đơn giản hóa việc đọc.
Domain: Kiểm thử đơn vị Use Cases với Repository giả — Kotlin/Swift thuần túy không cần Android SDK. Data: Kiểm thử tích hợp RepositoryImpl với DataSource giả. Presentation: Kiểm thử ViewModel với UseCase giả. Nhờ Dependency Rule, mỗi lớp được kiểm thử riêng biệt. Tại IT Sectr, mức độ bao phủ Domain đạt 95%, Data — 70–80%, Presentation — 60–70%.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm