Clean Architecture — nền tảng, các lớp Entities, Use Cases và Gateways

Tác giả: IT Sectr Đã đăng: 2026-02-17 Thời gian đọc: 10 phút

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 — ba lớp: Domain (logic kinh doanh), Data (dữ liệu), Presentation (UI) với Dependency Rule
  • Dependency Rule — các phụ thuộc hướng vào trong, Domain không biết về Data và Presentation
  • Use Cases (Interactors) — các kịch bản logic kinh doanh, mỗi Use Case là một lớp với một phương thức
  • Repository Interface — trừu tượng hóa dữ liệu trong Domain, triển khai ở lớp Data
  • Khả năng kiểm thử — Domain và Use Cases được kiểm thử đơn vị mà không cần Android SDK và iOS UIKit

Clean Architecture — nền tảng của kiến trúc đa lớp

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ớpChứaPhụ thuộc
DomainEntities, Use Cases, Repository InterfacesKhông (Kotlin/Swift thuần túy)
DataRepositoryImpl, DataSources (API, DB, Bộ nhớ đệm)Domain, Retrofit, Room, Ktor
PresentationViewModels, Views, ComposablesDomain, 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ể.

Lớp Domain: Entities, Use Cases và Repository Interfaces

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.

swift
// 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 Repository và DataSources

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.

kotlin
// 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: ViewModels và Views

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ị.

kotlin
// 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 iOS và Android: ví dụ mã

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.

swift
// 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

Clean Architecture nên có bao nhiêu lớ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.

Clean Architecture có làm tăng khối lượng mã không?

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ó thể kết hợp Clean Architecture với MVI khô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.

Tôi có cần Use Cases cho mọi yêu cầu dữ liệu không?

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.

Làm thế nào để kiểm thử Clean Architecture?

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

  • Clean Architecture — ba lớp (Domain, Data, Presentation) với Dependency Rule hướng vào trong
  • Dependency Rule — Domain không biết về Data và Presentation, cách ly qua giao diện
  • Domain — Entities, Use Cases, Repository Interfaces — Kotlin/Swift thuần túy không framework
  • Data — RepositoryImpl, DataSources (mạng, DB, bộ nhớ đệm) — triển khai giao diện Domain
  • Presentation — ViewModels, Views — chỉ hiển thị, logic kinh doanh trong Use Cases
  • Kiểm thử — Domain được bao phủ bởi kiểm thử đơn vị 90–95%
  • KMP — Clean Architecture với Kotlin Multiplatform mang lại 60–80% mã dùng chung iOS + Android

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.

Thảo luận dự án

Đọc thêm