Dan Tech Academy
Sau bài này bạn sẽ
  • Giải thích dependency rule: lớp ngoài biết lớp trong, lớp trong không biết lớp ngoài
  • Map bốn lớp UI, ViewModel, Domain, Data vào module thật của repo
  • Lần theo một lượt bắt đầu game từ GameRoute tới MockQuestionRepository và ngược lại
Thời lượng25 phút
Độ khóIntermediate

Clean Architecture Và Mô Hình 4 Lớp

Dependency rule, bốn lớp UI, ViewModel, Domain, Data map vào module thật của repo, và một lượt bắt đầu game đi qua cả bốn lớp.

Video đang cập nhật

Video bài học sắp lên sóng

Trong lúc chờ, bạn hãy đọc bản text của bài học bên dưới: đầy đủ lý thuyết, code mẫu và checklist thực hành.

Bản quay đang trong khâu hậu kỳ

Mục Tiêu Bài Học

Sau bài này, bạn giải thích được Clean Architecture giải quyết vấn đề gì, đọc được dependency rule, và chỉ ra bốn lớp UI, ViewModel, Domain, Data nằm ở module nào trong repo kotlin-accelerator-ai.

Mục tiêu cuối cùng: nhìn một class bất kỳ, bạn biết nó thuộc lớp nào và được phép import những gì.

Phần 1: Vấn Đề Clean Architecture Giải Quyết

Hãy bắt đầu bằng một ViewModel tự gọi database:

kotlin
// Ví dụ minh họa, không có trong repo
class GameViewModel(private val supabase: SupabaseClient) : ViewModel() {
    fun loadQuestions() = viewModelScope.launch {
        val rows = supabase.from("questions").select().decodeList<QuestionRow>()
        // map, lọc, xử lý lỗi... tất cả ở đây
    }
}

Code này chạy được, nhưng có ba cái giá:

  • Muốn test ViewModel, bạn cần một Supabase thật hoặc một mock rất phức tạp.
  • Đổi nguồn dữ liệu (ví dụ từ Supabase sang file JSON) nghĩa là sửa ViewModel.
  • Logic nghiệp vụ (chọn câu hỏi, tính tiền thưởng) bị trộn với chi tiết mạng.

Clean Architecture tách các mối quan tâm đó ra thành lớp, và đặt một luật duy nhất cho hướng phụ thuộc giữa chúng.

Phần 2: Dependency Rule

Luật gốc trong bài viết của Robert C. Martin: source code dependency chỉ được trỏ vào trong, về phía policy cấp cao. Lớp bên trong không biết tên bất cứ thứ gì ở lớp bên ngoài.

Áp vào app mobile:

LớpBiết vềKhông được biết về
UIViewModel, UI stateRepository, SDK mạng, database
ViewModelUse case, model của domainSupabase, DataStore, Firebase
DomainChính nó (model, interface, rule)Compose, Android, Koin, Ktor, Supabase
DataInterface của domain để implementUI, ViewModel

Điểm quan trọng nhất nằm ở hàng Data. Data phụ thuộc vào Domain, không phải ngược lại. Domain khai báo interface QuestionRepository, còn Data cung cấp class implement nó. Kỹ thuật đảo chiều phụ thuộc này gọi là Dependency Inversion.

Phần 3: Bốn Lớp Trong Repo

Repo có 14 Gradle module (khai báo trong settings.gradle.kts). Bốn lớp map vào chúng như sau:

LớpNằm ở đâuVí dụ thật
UIPackage screen trong :feature:game, :feature:menu, :feature:paywall, cộng :ui-components và :themeGameRoute, GameScreenCompose
ViewModelPackage viewmodel trong các feature moduleGameViewModel, MenuViewModel
DomainModule :domainGameStateMachine, UseCase, interface QuestionRepository
DataModule :dataMockQuestionRepository, LeaderboardRepositoryImpl, DataModule

Ngoài bốn lớp còn hai module làm nhiệm vụ lắp ráp: :shared-app (Koin wiring dùng chung cho Android và iOS) và :app (composition root của Android). Chúng là nơi duy nhất được nhìn thấy cả Domain lẫn Data cùng lúc.

Phần 4: Module Graph Giữ Luật Cho Bạn

Trong KMP, dependency rule không chỉ là quy ước. Nó nằm trong file Gradle. Đây là KDoc ở đầu domain/build.gradle.kts:

kotlin
/**
 * Pure Kotlin. Coroutines, the DRE reducer, and persistent collections.
 *
 * No Compose, navigation, Compose resources, Koin, Ktor, or Supabase.
 * Koin wiring for these types lives in `:shared-app`.
 */

commonMain của :domain chỉ khai báo ba dependency: kotlinx-coroutines-core, :dre-core và kotlinx-collections-immutable. Không có androidMain, không có iosMain. Vì vậy code domain chạy giống hệt nhau trên cả hai platform và test được bằng commonTest.

Phía feature, feature/game/build.gradle.kts khai báo :core, :domain, :kernel, :theme, :ui-components. Không có :data. Nếu bạn thử import MockQuestionRepository trong GameViewModel, build sẽ báo unresolved reference ngay. Compiler đang enforce dependency rule thay cho code review.

Phần 5: Theo Một Lượt Chơi Qua Bốn Lớp

Hãy theo hành động bắt đầu game, từ UI xuống Data rồi quay lên.

UI: GameRoute gọi gameViewModel.ensureGameStarted(), sau đó collect gameScreenUiState và truyền vào GameScreenCompose. Composable không biết câu hỏi đến từ đâu.

ViewModel: GameViewModel dispatch GameAction.StartGame. Constructor của nó nhận use case, không nhận repository:

kotlin
class GameViewModel(
    reducer: GameStateMachine,
    // ...
    logAnalytics: LogAnalyticsEventUseCase,
    loadGameQuestions: LoadGameQuestionsUseCase,
    lifelineHelper: LifelineHelper,
    recordGameStats: RecordGameStatsUseCase,
    dispatchers: QzDispatchers,
    // ...
) : DreStoreViewModel<GameState, GameAction, GameSideEffect, GameAsyncOp>(
    reducer = reducer,
    dispatchContext = dispatchers.mainImmediate,
), IGameViewModel

Domain: GameStateMachine là reducer thuần. Hàm handleStartGame trả về state mới kèm GameAsyncOp.LoadGame. Reducer không làm I/O; nó chỉ mô tả việc cần làm.

ViewModel thực thi async op qua GameAsyncOpExecutor.loadGame(), hàm này gọi LoadGameQuestionsUseCase:

kotlin
class LoadGameQuestionsUseCase(
    private val questionRepository: QuestionRepository,
) : UseCase<Unit, List<QuizData>>() {
    override suspend fun invoke(input: Unit): List<QuizData> = questionRepository.newGameQuestions()
}

Data: DataModule bind interface vào implementation:

kotlin
single<QuestionRepository> { MockQuestionRepository() }

MockQuestionRepository lấy câu hỏi từ SampleQuestions theo phân bổ độ khó trong QuizGameConfig.QUESTION_DIFFICULTIES.

Đi lên: executor tạo CurrentGameSession (id bằng Uuid.random() của Kotlin stdlib, chạy được trên cả Android và iOS), ViewModel dispatch GameAction.GameLoaded, reducer cập nhật state, và gameScreenUiState phát giá trị mới cho UI.

Khi đổi MockQuestionRepository sang một implementation đọc Supabase, chỉ dòng bind trong DataModule thay đổi. GameViewModel, GameStateMachine và LoadGameQuestionsUseCase không cần sửa.

Bài Tập Thực Hành

Nhiệm Vụ

Map tính năng Leaderboard vào bốn lớp. Với mỗi lớp, ghi tên class thật và lý do nó thuộc lớp đó.

Yêu Cầu

  1. Tìm class ở mỗi lớp: UI, ViewModel, Domain, Data.
  2. Chỉ ra interface nào nằm ở Domain và class nào implement nó.
  3. Trả lời: thứ hạng (rank) được tính ở lớp nào?
  4. Trả lời: vì sao LeaderboardViewModel không import được LeaderboardRepositoryImpl?
LớpClassLý do
UILeaderboardPopup, LeaderBoardList trong :feature:menuChỉ render LeaderBoardListUiState
ViewModelLeaderboardViewModelGọi FetchLeaderboardUseCase, map LeaderboardEntry sang UserProfileUiState
DomainFetchLeaderboardUseCase, interface LeaderboardRepository, model LeaderboardEntryKhông import SDK nào
DataLeaderboardRepositoryImpl, LeaderboardRowGọi Supabase, decode row, trả DomainResult

Interface LeaderboardRepository nằm trong :domain, LeaderboardRepositoryImpl trong :data implement nó, và DataModule bind hai thứ lại với nhau.

Rank được tính ở database. View leaderboard trong scripts/sql/setup.sql dùng ROW_NUMBER() OVER (ORDER BY total_money_won DESC, level DESC, id). Repository chỉ yêu cầu sắp xếp theo cột rank và giới hạn số dòng. ViewModel dùng giá trị rank có sẵn để chọn tier GOLD, SILVER, BRONZE.

LeaderboardViewModel nằm trong :feature:menu, và module này không khai báo dependency vào :data. Import đó sẽ không compile.

Lộ Trình Đọc Code

  1. settings.gradle.kts: danh sách 14 module.
  2. domain/build.gradle.kts: KDoc nói rõ domain không được chứa gì.
  3. feature/game/build.gradle.kts: feature không phụ thuộc :data.
  4. GameViewModel: constructor nhận use case.
  5. GameStateMachine: handleStartGame và handleSubmitAnswer.
  6. DataModule: interface nào bind vào class nào.

Dấu Hiệu Cần Tránh

SmellHướng tốt hơn
ViewModel nhận SupabaseClient hoặc repository implementationNhận use case; implementation được bind ở composition root
Domain import androidx.*, Compose hoặc KoinGiữ domain ở commonMain với Kotlin thuần
Feature module thêm dependency :data cho tiệnThêm interface vào :domain, implement ở :data
Composable tự kiểm tra đáp án đúng saiPhát intent cho ViewModel; reducer quyết định
Thêm lớp chỉ để có đủ bốn lớpMỗi lớp phải tách được một lý do thay đổi

Tham Khảo

Ý Chính

Clean Architecture là một luật về hướng phụ thuộc: lớp ngoài biết lớp trong, lớp trong không biết lớp ngoài.

Trong repo này, luật đó được viết vào Gradle. :domain chỉ có Kotlin thuần, feature module không thấy :data, và chỉ :shared-app cùng :app được nhìn thấy cả hai để lắp ráp.

Khi bạn phân vân một class thuộc đâu, hãy hỏi: nếu đổi SDK hoặc đổi UI, class này có phải sửa không? Nếu có, nó không thuộc domain.

Đăng nhập