Dan Tech Academy
Sau bài này bạn sẽ
  • Đặt đúng trách nhiệm vào từng layer: UI, Route, ViewModel, reducer, use case, repository, service
  • Phân biệt repository contract (truy cập dữ liệu) với service contract (capability) và data implementation
  • Đọc naming convention của codebase để đoán vị trí và vai trò của một file trước khi mở nó
Thời lượng7 phút
Độ khóBeginner

Trách Nhiệm Từng Layer Và Cách Đọc Tên File

Đừng trộn UI, business logic và backend code. Và học cách đoán vị trí file chỉ từ tên của nó.

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

Trong app thật, viết được tính năng mới là một nửa. Nửa còn lại là biết trách nhiệm đó nên nằm ở layer nào.

Bài này giúp bạn phân biệt trách nhiệm của UI, Route, ViewModel, reducer/state machine, use case, repository, service, data implementation và shared UI layers.

Phần sau của bài dạy một kỹ năng ít được nói tới: đọc tên file để đoán vai trò và vị trí của nó. Codebase này đặt tên rất nhất quán. Khi nắm được quy ước, bạn tìm code nhanh hơn nhiều so với search mò.

Trong một project Kotlin Multiplatform, câu hỏi "layer nào" đi kèm câu hỏi thứ hai: code này chạy chung cho mọi nền tảng, hay chỉ trên Android hoặc iOS? Phần 5 trả lời câu đó.

Vấn Đề Thật Sự

Trong app nhỏ, bạn có thể viết gần như mọi thứ vào ViewModel và app vẫn chạy. Trong production app, cách đó bắt đầu khó khăn.

Bạn sửa một bug sign-in nhỏ nhưng phải mở UI, ViewModel, Supabase client, analytics, navigation và local storage. Bạn thêm một button nhưng vô tình chạm business rule. Bạn đổi provider AI hint nhưng game screen cũng phải sửa.

Lúc đó các layer không còn ownership rõ ràng.

Đây cũng là điểm nghẽn hay gặp khi vibe coding: build app rất nhanh, nhưng prompt không nói rõ trách nhiệm của từng layer, nên code dồn hết vào một chỗ.

Phần 1: Layer Map

Dùng bảng này khi bạn không chắc nên đặt code ở đâu:

LayerTrách nhiệm chínhKhông nên làm gì
UI composableRender UiState, nhận input, gọi callbacks.Gọi backend, giữ business rule, tự quyết định data source.
Route composableConnect ViewModel với screen, collect one-time effects, nối navigation.Nhồi layout hoặc business logic.
ViewModelGiữ screen state, nhận UI events, dispatch actions, gọi use case/service.Tự tạo HTTP client, tự biết Supabase/Firebase SDK detail.
Reducer/state machineBiến current state cộng action thành next state và effect một cách predictable.Gọi I/O, navigation, analytics hoặc backend trực tiếp.
Use caseDiễn đạt một business operation cụ thể.Render UI hoặc biết Compose classes.
Repository contractĐịnh nghĩa app cần đọc/ghi data gì.Chứa SDK/provider implementation detail.
Service contractĐịnh nghĩa capability như auth, AI hint, audio, haptic, subscription.Trả về UI state hoặc phụ thuộc screen.
Data implementationNói chuyện với Supabase, Firebase, RevenueCat, DataStore, Ktor, audio SDK của từng nền tảng.Quyết định UI flow.
:coreShared route contracts, base types, feature registries.Sở hữu feature UI.
:kernelPrimitive kỹ thuật thấp nhất, ví dụ QzDispatchers.Phụ thuộc vào module khác của app.
:ui-components và :themeReusable composables, design tokens, spacing, typography, colors.Gọi ViewModel hoặc backend.

Người học hay bỏ qua điểm này: Repository và Service khác nhau.

Repository trả lời câu hỏi "data này được lưu và đọc như thế nào?". Service trả lời "capability này được thực hiện như thế nào?".

Ví dụ LeaderboardRepository đọc bảng xếp hạng, còn AuthService xử lý sign-in và sign-out. Cả hai đều có contract ở :domain và implementation ở :data.

Reducer, side effect handler và ViewModel của các feature được xây trên thư viện nội bộ ở hai module :dre-core và :dre-viewmodel. MenuReducer implement interface Reducer của :dre-core, các handler implement SideEffectHandler, và MenuViewModel kế thừa DreStoreViewModel của :dre-viewmodel. Vì mọi feature dùng chung một bộ khung, shape của các thư mục ViewModel ở Phần 4 mới giống nhau đến vậy.

Phần 2: Ví Dụ về Email Sign In

Trace flow này:

text
Menu screen
-> MenuViewModel
-> SignInWithEmailUseCase
-> AuthService
-> AuthServiceSupabaseImpl
-> Supabase

Trách nhiệm đúng nên là:

PieceOwner
Email form UI:ui-components auth components hoặc :feature:menu screen code
User bấm sign inMenuRoute hoặc MenuScreen callback vào MenuViewModel
Loading/error/dialog stateMenuViewModel và reducer
Business operationSignInWithEmailUseCase trong :domain
Auth capabilityAuthService contract trong :domain
Supabase detailAuthServiceSupabaseImpl trong :data

Nếu MenuViewModel tự tạo Supabase client, nó đang ôm quá nhiều: screen state, business flow và transport detail.

Hậu quả rất cụ thể: đổi auth provider thì menu phải sửa. Thêm retry policy thì menu phải biết. Reuse sign-in ở onboarding thì copy logic.

Phần 3: Ví Dụ về AI Hint

Trace flow này:

text
Game feature
-> GameViewModel
-> lifeline/action handling
-> AIHintService
-> AIHintServiceImpl
-> GeminiHintProvider hoặc LocalHintProvider
-> GeminiHintClient (SupabaseGeminiHintClient), chỉ ở nhánh Gemini
-> Edge Function gemini-hint trên Supabase
-> Gemini API

Với user, đây chỉ là một hành động: "cho tôi một gợi ý". Với codebase, nó đi qua nhiều layer:

Trách nhiệmLayer hợp lý
Render nút Ask AIGame screen composable
Nhận user intentGameViewModel
Quyết định game state đang cho phép hint khôngReducer/state machine
Diễn đạt capability "get hint"AIHintService contract
Chọn Gemini hay local fallbackData implementation và provider wiring

Screen nên render nút Ask AI. ViewModel nên điều phối state. Data layer mới biết request đi đâu và lỗi thì làm gì. GeminiHintProvider chỉ chạy khi flag feature_ai_hints trong Remote Config đang bật và người chơi đã đăng nhập. Nó gửi câu hỏi và các đáp án qua SupabaseGeminiHintClient tới Edge Function gemini-hint, và khi gặp lỗi tạm thời thì thử lại tối đa max_retries lần (đọc từ Remote Config). Gọi thất bại thì AIHintServiceImpl chuyển sang LocalHintProvider. Gemini API key không nằm trong app: nó là secret của Supabase, và chỉ function gemini-hint đọc được.

Phần 4: Đọc Tên File Như Đọc Bản Đồ

Đây là kỹ năng điều hướng nhanh nhất trong codebase production. Mỗi hậu tố tên file cho bạn biết vai trò và module của nó trước khi bạn mở file.

Nhóm tiền tố

Tiền tốNghĩaModule
Qzds*Design system component hoặc token dùng chung:ui-components hoặc :theme
I*ViewModelInterface của ViewModel, dùng cho test và preview:feature:*
*ImplImplementation cụ thể của một contractthường là :data

Tiền tố Qzds xuất hiện ở rất nhiều file. Khi bạn thấy nó, bạn biết ngay: đây là thứ dùng chung, không thuộc riêng feature nào, và sửa nó sẽ ảnh hưởng nhiều screen.

Nhóm hậu tố trong một feature

Hậu tốVai trò
*RouteDataRoute contract, @Serializable, nằm ở :core
*NavigationFeature navigation provider, register destination
*RouteComposable nối ViewModel với screen, xử lý one-time effect
*ScreenComposable render thuần từ UiState
*UiStateImmutable data class mô tả những gì screen hiển thị
*MapperChuyển state nội bộ thành UiState
*ContractKhai báo State, Action, SideEffect của một màn hình
*ReducerHàm thuần biến state cộng action thành state mới
*SideEffectHandler, *UiEffectHandlerNơi effect được thực thi thật, có I/O
*ViewModelCoordinator nối tất cả những thứ trên lại

Mở feature/menu/.../viewmodel/ bạn sẽ thấy đúng bộ này: MenuContract.kt, MenuReducer.kt, MenuViewModel.kt, cộng thư mục handler/ chứa 4 side effect handler.

Rồi mở feature/menu/.../settings/viewmodel/. Lại đúng bộ đó: SettingContract.kt, SettingReducer.kt, SettingUiEffectHandler.kt, SettingViewModel.kt.

Rồi leaderboard/viewmodel/. Vẫn bộ đó.

Codebase không có mười cách tổ chức cho mười màn hình. Nó có đúng một shape, lặp lại ở mọi nơi.

Khi bạn hiểu shape đó một lần, bạn đọc được mọi feature còn lại. Và khi bạn viết feature mới, bạn đã biết cần tạo file nào.

Phần 5: Layer Và Nền Tảng

Mọi layer trong bảng Phần 1 đều nằm trong commonMain: UI composable, Route, ViewModel, reducer, use case, contract, và phần lớn data implementation. Android và iOS dùng chung một bản.

Chỉ những implementation phải gọi SDK riêng của một nền tảng mới tách ra. Ví dụ contract AudioManager nằm trong commonMain, còn implementation có hai bản:

Contract (commonMain)Android (DataModule.android.kt)iOS (DataModule.ios.kt)
AudioManagerSoundPoolAudioManagerAvAudioManager
HapticManagerVibratorHapticManagerUiKitHapticManager
AnalyticsManagerFirebaseAnalyticsManagerNoOpAnalyticsManager

Feature chỉ biết AudioManager. Koin chọn implementation theo nền tảng. Trong UI dùng chung không có nhánh if (platform) nào: một composable, hai Koin binding.

Khi thêm code chạm tới SDK nền tảng, đặt contract ở commonMain và hai implementation ở androidMain/iosMain. Mẹo này áp dụng cho mọi project KMP, không riêng repo này.

Phần 6: ViewModel Không Làm Tất Cả

Một hiểu lầm phổ biến: vì reducer và handler được ViewModel điều phối, nên mọi thứ phải đưa vào ViewModel.

ViewModel chỉ điều phối: nhận UI events, expose UiState, dispatch actions, gọi reducer, và execute effects qua collaborator đã inject.

Business rule phức tạp vẫn nên nằm trong use case. Data access vẫn nên đi qua repository/service contract. Provider detail vẫn nằm trong :data.

Bad Smells

SmellHướng tốt hơn
Composable import Supabase hoặc Firebase classesĐưa service call về sau domain/data layers.
ViewModel tự tạo HttpClientInject repository/service implementation.
Reducer hoặc state machine gọi I/O trực tiếpEmit side effect, execute ở handler.
Repository trả về UiStateTrả về domain model, map sang UI ở feature layer.
Service implementation nằm trong :domainGiữ domain là contract, implementation ở :data.
Feature copy một button có thể dùng lại nơi khácExtract sang :ui-components với tiền tố Qzds.
Feature A import screen của Feature BDùng route contracts và callbacks.
Composable chia nhánh theo nền tảngĐặt contract ở commonMain, bind implementation theo nền tảng qua Koin.

Checkpoint

Mở feature/menu/.../viewmodel/ và feature/menu/.../settings/viewmodel/ cạnh nhau.

Bạn hoàn tất bài này khi trả lời được:

  • Hai thư mục đó có chung shape gì?
  • File nào là nơi duy nhất được phép gọi I/O trong shape đó?
  • Chỉ nhìn tên LeaderboardUiState.kt, bạn đoán được nó chứa gì và không chứa gì?
  • AIHintService và AIHintServiceImpl khác nhau ở đâu, và mỗi cái nằm module nào?
  • Nếu bạn cần một button dùng chung cho cả menu và game, bạn đặt nó ở đâu và đặt tên thế nào?
  • Vì sao AudioManager có hai implementation, và feature có cần biết điều đó không?
Đăng nhập