Clean Architecture: Khi niềm tin trở thành sai lầm.
Clean Architecture thường được xem là chuẩn mực vàng cho lập trình di động, nhưng liệu nó có luôn đúng? Bài viết phân tích cái giá phải trả cho việc tuân thủ mù quáng nguyên tắc Đảo ngược Sự phụ thuộc (Dependency Inversion) và lý do tại sao kiến trúc phân tầng truyền thống lại phù hợp hơn cho các team Agile.
Trong thế giới lập trình di động, đặc biệt là hệ sinh thái Android, Clean Architecture đã phát triển vượt xa khuôn khổ của một mẫu thiết kế phần mềm để trở thành một dạng "niềm tin". Nó thống trị các bài diễn thuyết, bài viết chuyên môn và các cuộc tranh luận cộng đồng. Quan điểm phổ biến hiện nay cho rằng nếu bạn không tuân thủ nghiêm ngặt Clean Architecture, code của bạn mặc định là sai lầm, khó mở rộng, hoặc "bẩn" (dirty).
Bài viết này không nhằm mục đích công kích, mà để nhìn nhận Clean Architecture một cách thực tế và kỹ thuật hơn. Chúng ta sẽ phân tích xem khi nào nó mang lại giá trị thực sự và khi nào nó chỉ tạo ra sự phức tạp không cần thiết.
Định nghĩa lại khái niệm: Thế nào là "Clean"?
Một trong những rào cản lớn nhất là sự mơ hồ về ngữ nghĩa. Mỗi lập trình viên lại có một cách hiểu khác nhau về "Clean Architecture". Với nhiều người, nó đơn giản là viết code dễ đọc, tách biệt rõ ràng và sử dụng interface.
Robert C. Martin (Uncle Bob) đã có một nước đi marketing tuyệt vời. Không giống như Onion Architecture (kiến trúc Củ hành) hay Hexagonal Architecture (kiến trúc Lục giác) chỉ mô tả hình dáng, cái tên Clean Architecture mang tính định giá trị. Nếu bạn không dùng nó, chẳng lẽ kiến trúc của bạn là "dơ"? Tâm lý này thường ngăn cản các cuộc thảo luận kỹ thuật khách quan.
Trong bài viết này, chúng ta sẽ coi Clean Architecture đúng theo định nghĩa của Robert Martin: cấu trúc phân tầng dạng vòng tròn đồng tâm và tuân thủ nghiêm ngặt Quy tắc Phụ thuộc (Dependency Rule).
Cơ chế cốt lõi: Đảo ngược Sự phụ thuộc (Dependency Inversion)
Đặc điểm nhận dạng của Clean Architecture trong mobile là Dependency Inversion (DIP). Mặc dù việc phân tách trách nhiệm (separation of concerns) tồn tại ở nhiều mô hình, nhưng DIP mới là thứ tạo nên sự khác biệt của Clean Architecture.
Quy tắc này quy định rằng các phụ thuộc của source code chỉ được hướng vào bên trong. Không thành phần nào ở vòng trong được phép biết bất cứ điều gì về các thành phần ở vòng ngoài. Lý tưởng nhất:
- Tầng Domain (Domain Layer) nằm ở trung tâm. Nó chứa logic nghiệp vụ thuần túy và không phụ thuộc vào ai cả.
- Các tầng bên ngoài (UI, Data, Frameworks) phụ thuộc vào Domain.
So sánh với Kiến trúc Phân tầng (Layered Architecture)
Để hiểu cái giá phải trả cho Clean Architecture, ta cần so sánh nó với Layered Architecture truyền thống. Trong mô hình phân tầng cổ điển, sự phụ thuộc chảy xuôi dòng từ trên xuống dưới: Presentation → Domain → Data.
Ở mô hình này, một ViewModel gọi UseCase, và UseCase này gọi trực tiếp class Repository (hoặc qua interface được định nghĩa ở tầng Data).
Nhược điểm của cách này là nếu tầng Data thay đổi (ví dụ đổi từ SQL sang Realm, hay đổi cấu trúc DTO), sự thay đổi đó có thể lan truyền ngược lên tầng Domain. Để ngăn chặn điều này, Clean Architecture đảo ngược mũi tên phụ thuộc.
Bằng cách áp dụng Dependency Inversion, tầng Domain sẽ tự định nghĩa một interface (ví dụ UserRepository), và tầng Data sẽ implement interface đó. Domain không còn phụ thuộc Data; Data phải phụ thuộc vào Domain.
Cái giá phải trả trong Lập trình Di động
Về lý thuyết, sự cô lập này là hoàn hảo. Nhưng trong thực tế, đặc biệt là với Android/iOS, nó tạo ra những ma sát không đáng có.
1. Cơn ác mộng Đa Module (Multi-Module)
Các dự án mobile hiện đại thường được module hóa để tăng tốc độ build. Cấu trúc tiêu biểu do Google đề xuất thường trông như sau:
Khi bạn cố ép Clean Architecture vào các module này, rủi ro phụ thuộc vòng (circular dependencies) xuất hiện. Nếu bạn có một module dùng chung :data:user và các module tính năng :feature:home, :feature:profile, việc áp dụng Dependency Inversion trở nên rất rối rắm.
Nếu bạn đặt repository interface ở tầng Domain của Feature, nhưng implementation lại nằm ở module Shared Data, làm sao để kết nối chúng mà không khiến Shared Data phải phụ thuộc ngược lại vào từng Feature module? Bạn buộc phải tạo ra một module "Shared Domain" riêng chỉ để chứa interface, hoặc chấp nhận lặp code.
// Sự phức tạp phát sinh từ việc đảo ngược nghiêm ngặt
package com.example.feature1.domain
// Nếu đặt interface ở đây...
interface UserRepository { /* ... */ }
class GetMenuUseCase(
private val userRepository: UserRepository,
) { /* ... */ }
// ...thì module Shared Data phải phụ thuộc vào Feature 1 để implement nó.
// Điều này phá vỡ kiến trúc nếu Feature 2 cũng cần User Data.
Bạn bị buộc phải tạo ra các biểu đồ phụ thuộc phức tạp chỉ để thỏa mãn quy tắc "Domain không được biết về Data", ngay cả khi tầng Data (ví dụ như User Repository) thực tế là một trong những phần ổn định nhất của ứng dụng.
Lập trình viên thường phải chọn cách tạo module Data vụn vặt cho từng feature hoặc một module Shared Domain khổng lồ, cả hai đều làm tăng thời gian bảo trì và độ phức tạp khi build.
2. Huyền thoại về "Tầng Domain ổn định"
Tiền đề của Uncle Bob là Logic nghiệp vụ thì ổn định còn Hạ tầng kỹ thuật thì hay thay đổi. Do đó, ta bảo vệ cái ổn định khỏi cái dễ thay đổi.
Trong mobile, thực tế hoàn toàn trái ngược. Chúng ta làm việc theo Agile. "Logic nghiệp vụ" (màn hình hoạt động ra sao, A/B test, luồng đi...) thay đổi qua mỗi Sprint. Trong khi đó, Retrofit client, Room database, và cấu trúc User object có khi nằm im cả năm trời không đổi.
// Logic thay đổi liên tục theo yêu cầu sản phẩm
class CheckoutUseCase(
private val cartRepository: CartRepository,
private val discountService: DiscountService,
private val abTestManager: AbTestManager,
) {
fun execute(): CheckoutResult {
val items = cartRepository.getItems()
// Logic nghiệp vụ thay đổi liên tục ở đây dựa trên nhu cầu marketing
val discounts = if (abTestManager.isFeatureEnabled("new_discount")) {
discountService.calculateNew(items)
} else {
discountService.calculateLegacy(items)
}
return CheckoutResult(items, discounts)
}
}
Nếu Domain là phần dễ thay đổi nhất của ứng dụng, việc xây rào chắn bảo vệ nó khỏi tầng Data ổn định bằng Dependency Inversion là sự kỹ thuật hóa quá mức (over-engineering). Bạn đang trả một cái giá đắt về kiến trúc chỉ để bảo vệ một thành phần mà tuần sau có thể bạn sẽ phải viết lại.
Sự trở lại của Kiến trúc Phân tầng thực dụng
Với nhiều ứng dụng mobile, Kiến trúc Phân tầng "cổ điển" không phải là sai lầm — nó là lựa chọn tối ưu. Nó phù hợp với cách Android và iOS SDK được cấu trúc.
Layered Architecture không có nghĩa là "Spaghetti Code". Bạn vẫn dùng các nguyên lý SOLID. Bạn vẫn dùng UseCase để đóng gói logic. Bạn vẫn dùng interface ở nơi cần thiết để test. Nhưng bạn loại bỏ sự gò bó giáo điều rằng tầng Domain tuyệt đối không được biết gì về tầng Data.
Kết luận
Clean Architecture là một công cụ mạnh mẽ cho các hệ thống phức tạp, quy mô lớn với vòng đời dài và hạ tầng thay đổi liên tục. Tuy nhiên, coi nó là mặc định cho mọi dự án mobile là một sai lầm.
Khi yêu cầu nghiệp vụ thay đổi nhanh hơn hạ tầng kỹ thuật, và khi việc chia module trở thành cơn đau đầu chỉ để thỏa mãn các quy tắc phụ thuộc, hãy lùi lại một bước. Một kiến trúc phân tầng được tổ chức tốt thường mang lại 80% lợi ích với chỉ 20% sự phức tạp so với Clean Architecture.
Phản Ứng Của Bạn Là Gì?
Thích
0
Không Thích
0
Yêu
0
Hài hước
0
Giận dữ
0
Buồn
0
Wow
0