Scaling Android Architecture #3: Domain Driven Android — Building a Model which makes sense
Trong thế giới phát triển Android, có sự cạnh tranh liên tục để có kiến trúc tốt nhất. MVC vs MVP vs MVVM vs MVI là một chủ đề phổ biến của nhiều bài viết trên Internet.
Chúng ta biết rằng View không được chứa bất kỳ logic phức tạp nào. Chúng ta biết sự khác biệt giữa Controller, Presenter và ViewModel là gì. Nhưng chúng ta có biết cách xây dựng một Model thích hợp cho ứng dụng của mình không?
Chống Domain Model anti-pattern
Thông thường, chúng ta coi Model là một tập hợp các classes chỉ chứa dữ liệu. Không có gì khác. Sau đó, tất cả logic được triển khai trong ViewModels, Use Case, Repositories hoặc Services sửa đổi các Model classes này.
Điều kinh dị cơ bản của kiểu anti-pattern này là nó quá trái ngược với ý tưởng cơ bản của thiết kế hướng đối tượng; đó là kết hợp data và process với nhau. Anemic Domain model thực sự chỉ là một thiết kế theo phong cách thủ tục.
Tệ hơn nữa, nhiều người nghĩ rằng các anemic domain là các object thực, và do đó hoàn toàn bỏ qua ý nghĩa của thiết kế hướng đối tượng.
Nó được mô tả bởi Martin Fowler trong một trong những bài đăng trên blog của anh ấy. Tôi muốn nói rằng anti-pattern này là một trong những mô hình phổ biến nhất, được sử dụng rộng rãi trong nhiều ứng dụng.
https://martinfowler.com/bliki/AnemicDomainModel.html
Domain Driven Android
Lưu ý đến Domain Driven Design, chúng ta có thể xây dựng một Model có ý nghĩa, không chỉ chứa dữ liệu mà còn chủ yếu mô tả và giải quyết các vấn đề business trong thế giới thực. Với mục đích của bài viết này, tôi đã chuẩn bị một ứng dụng Domain Driven Android mà chúng ta sẽ sử dụng làm ví dụ. Bạn có thể tìm thấy nó trên GitHub ⬇️
https://github.com/Maruchin1/domain-driven-android
Nó là một ứng dụng khách hàng thân thiết cho một nhà hàng thức ăn nhanh. Người dùng thu thập điểm để mua thực phẩm. Sau đó, họ có thể đổi điểm đã thu thập để lấy phiếu giảm giá có sẵn trong ứng dụng di động.
1. Hiển thị Coupons
Yêu cầu đầu tiên của ứng dụng của chúng ta là hiển thị danh sách tất cả các Coupons có sẵn và có thể preview từng Coupons. Tất cả các Coupons đến từ một HTTP endpoint trả về JSON.
[
{
"id": "1",
"name": "Cheesburger with fries",
"points": 200,
"image": "https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/cheesburger_with_fries_coupon.jpeg"
},
{
"id": "2",
"name": "Chicekburger with fries",
"points": 150,
"image": "https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/chickenburger_with_fries_coupon.jpeg"
},
]
Tại thời điểm này, ứng dụng của chúng ta chỉ hiển thị dữ liệu từ máy chủ trên màn hình. Nhưng ngay cả như vậy, chúng ta đã có thể hưởng lợi từ việc sử dụng phương pháp DDD.
Defining Entities
Chúng ta bắt đầu bằng cách xác định một class Coupon
là một Entity. Các Entities đại diện cho các khái niệm chính của domain của chúng ta. Mỗi instance của một Entity có unique identity của nó. Chúng ta có thể xác định Coupon bằng thuộc tính id.
data class Coupon(val id: ID, ...)
Các thực thể cũng có vòng đời. Chúng có thể được tạo, cập nhật và xóa dựa trên identity của chúng.
Defining Values
Bên cạnh Entities, Model của chúng ta có thể bao gồm Values
. Trái ngược với Entities, Values không có identity và vòng đời. Mỗi Entitiy được xây dựng từ các Values hoặc các Entities lồng nhau khác. Trong Coupon
, chúng ta không có bất kỳ Entities lồng nhau nào, chỉ có Values.
data class Coupon(
val id: ID,
val name: Name,
val points: Points,
val image: URL,
)
Khi Value có nhiều fields, chúng ta có thể sử dụng một data class
để biểu diễn nó. Đối với Values với một field duy nhất, Kotlin cung cấp tính năng value class
. Chúng ta cũng có thể dựa vào các loại tích hợp sẵn như java.net.URL
.
@JvmInline
value class ID(val value: String)
@JvmInline
value class Name(val value: String)
@JvmInline
value class Points(val value: Int) {
init {
require(value >= 0)
}
}
Tại sao không phải là primitive types?
Khi chúng ta xác định các Model classes của mình, chúng ta thường chỉ sao chép cấu trúc của JSON bằng cách sử dụng các kiểu nguyên thủy như String hoặc Int . Việc triển khai như vậy hoạt động nhưng nó bỏ lỡ nhiều thông tin quan trọng.
data class Coupon(
val id: String,
val name: String,
val points: Int,
val image: String,
)
Entities và Values có thể đi kèm với các ràng buộc bổ sung
Chúng ta biết rằng Points
của chúng ta không đơn giản như nguyên thủy Int
. Business yêu cầu Points
không được âm. Yêu cầu này phải được Model phản ánh.
Khi chúng ta xác định Value Points
riêng biệt, chúng ta có thể thêm các ràng buộc bổ sung cho nó. Trong khối init
, bạn có thể thấy require
xác minh xem Points
có phải là số nguyên không âm hay không.
Các khái niệm khác nhau không nên được biểu diễn bằng cùng một loại
Sau đó, chúng ta có ba thuộc tính khác nhau, được gửi dưới dạng String
. Nhưng có phải tất cả chúng chỉ là một text? Chúng ta có thể thay thế các giá trị của name
và url
và mong muốn ứng dụng của chúng ta hoạt động bình thường không? Rõ ràng là không.
Chúng ta có thể cân nhắc sử dụng Values dành riêng cho data đại diện cho các khái niệm domain khác nhau. Theo cách này, chúng ta đang tạo Values đại diện cho ID
và Name
và chúng ta sử dụng loại URL
tích hợp cho image
.
Nếu bạn muốn biết thêm về Values, hãy xem một bài viết hay từ những người tạo ra thư viện Arrow. https://arrow-kt.io/learn/design/domain-modeling/
Thêm Repository
Tôi đã đề cập rằng mỗi Entity có một vòng đời. Để quản lý vòng đời này, chúng ta dựa vào Repository. Mỗi Repository chịu trách nhiệm quản lý một Entity duy nhất. Nó cung cấp các hoạt động CRUD cho phép chúng ta Create, Read, Update và Delete các instances của một Entity nhất định.
class CouponsRepository @Inject constructor(
private val couponsApi: CouponsApi,
private val scope: CoroutineScope,
) {
private val coupons: StateFlow<List<Coupon>> = flow {
emit(couponsApi.fetchAllCoupons())
}.map { couponsJson ->
couponsJson.toDomain()
}.shareIn(scope, SharingStarted.Lazily)
fun getAllCoupons(): Flow<List<Coupon>> {
return coupons
}
fun getCoupon(id: ID): Flow<Coupon?> {
return coupons.map { coupons ->
coupons.find {it.id == id }
}
}
}
Hiện tại đây là tất cả những gì chúng ta cần. Bởi vì không có yêu cầu logic nào khác, CouponsRepository
có thể được sử dụng trực tiếp trong ViewModels. Chúng ta sử dụng chúng để hiển thị danh sách tất cả các Coupons cũng như một Coupon riêng lẻ.
2. User Account với số điểm tích lũy được
Mỗi user phải tạo một Account để thu thập Points. Chúng ta muốn thông báo cho User khi họ có đủ Points để đổi lấy một Coupon cụ thể.
Trong ví dụ bên dưới, User của chúng ta có 170 Điểm. Các Coupons có giá cao hơn sẽ bị mờ đi. Điều tương tự cũng áp dụng cho màn hình Preview. Nếu User có đủ điểm thì nút “Collect” sẽ được bật, nếu không thì không thể nhấp vào nút này và phần giải thích bổ sung sẽ hiển thị.
Defining a new Entity
Bước đầu tiên là thể hiện khái niệm Account trong Model của chúng ta. Chúng ta đã biết cách thực hiện đúng cách bằng cách sử dụng Entity có Values.
data class Account(
val email: Email,
val collectedPoints: Points,
)
Như bạn có thể thấy, chúng ta sử dụng cùng một lớp Points
có trong Coupon
. Values này có một ý nghĩa cụ thể và các ràng buộc bổ sung under the hood. Nhờ có nó, chúng ta đang chia sẻ cùng một ý nghĩa và logic trong hai Entities khác nhau.
Adding Business Rules to the Model
Như đã mô tả ở trên, chúng ta biết rằng trong business của chúng ta, User sở hữu Account chỉ có thể nhận được một Coupon nhất định khi họ có đủ điểm để đổi. Quy tắc này có thể được mô tả dễ dàng trực tiếp trong Model.
data class Account(
val email: Email,
val collectedPoints: Points,
) {
fun canExchangePointsFor(coupon: Coupon): Boolean {
return collectedPoints >= coupon.points
}
}
Để làm cho logic này hoạt động, chúng ta cũng cần làm cho class Points
Comparable
của chúng ta
@JvmInline
value class Points(val value: Int) : Comparable<Points> {
init {
check(value >= 0)
}
override fun compareTo(other: Points): Int {
return value.compareTo(other.value)
}
}
Time for Use Cases
Chúng ta đã mô tả các vấn đề business của mình trong Model. Bây giờ chúng ta cần khả năng truy cập logic này từ các phần khác của ứng dụng. Với mục đích này, chúng ta giới thiệu hai Use Cases..
class GetAllCollectableCouponsUseCase @Inject constructor(
private val accountRepository: AccountRepository,
private val couponsRepository: CouponsRepository,
) {
operator fun invoke(): Flow<List<CollectableCoupon>> {
return combine(
accountRepository.getLoggedInAccount().filterNotNull(),
couponsRepository.getAllCoupons(),
) { account, coupons ->
allCoupons.sortedBy { coupon ->
coupon.points
}.map { coupon ->
CollectableCoupon(coupon, account)
}
}
}
}
class GetCollectableCouponUseCase @Inject constructor(
private val accountRepository: AccountRepository,
private val couponsRepository: CouponsRepository,
) {
operator fun invoke(couponId: ID): Flow<CollectableCoupon> {
return combine(
accountRepository.getLoggedInAccount.filterNotNull(),
couponsRepository.getCoupon(couponId).filterNotNull(),
) { account, coupon ->
CollectableCoupon(coupon, account)
}
}
}
Cả hai đều dựa trên cùng một class CollectableCoupon
. Class này không phải là Entity hay Value. Chúng ta chỉ sử dụng nó để biểu thị thông tin được tổng hợp và chuyển đổi có thể được trả về từ Use Case.
data class CollectableCoupon(
val coupon: Coupon,
val canCollect: Boolean,
) {
constructor(coupon: Coupon, account: Account) : this(
coupon = coupon,
canCollect = account.canExchangePointsFor(coupon),
)
}
Các Use Cases chỉ nên phối hợp công việc
Như bạn có thể thấy, các Use Cases không chứa bất kỳ business logic cụ thể nào. Chúng không quyết định khi nào Coupon có thể được thu thập hay không. Chúng chỉ điều phối một công việc và ủy thác mọi công việc cho Model.
Eric Evans, tác giả của cuốn sách Domain Driven Design cho biết:
Layer này được giữ mỏng. Nó không chứa các business rules or knowledge mà chỉ điều phối (coordinates) các nhiệm vụ và ủy nhiệm công việc cho các objects cộng tác của domain ở layer tiếp theo trở xuống.
Uncle Bob chia sẻ quan điểm tương tự trong Clean Architecture của mình:
Các use cases này sắp xếp flow of data đến và từ các entities, đồng thời chỉ đạo các entities đó sử dụng các business rules trong toàn doanh nghiệp của họ để đạt được các mục tiêu của use case.
3. Collecting the Coupon
Cho đến bây giờ, chúng ta chỉ đọc một số data. Phương pháp tiếp cận theo Domain Driven hữu ích ở đó, nhưng nó thực sự tỏa sáng khi chúng ta cố gắng triển khai logic làm thay đổi trạng thái của ứng dụng.
Trong lần lặp lại tiếp theo, chúng ta thêm một tùy chọn để kích hoạt một coupon cụ thể. Quá trình kích hoạt có một số bước:
- User chọn Coupon để kích hoạt.
- Ứng dụng kiểm tra xem User đã thu thập đủ Points hay chưa.
- Ứng dụng giảm một lượng Points của User theo giá trị của Coupon.
- Ứng dụng tạo Activation Code ngẫu nhiên.
- Ứng dụng gán Activation Code cho Coupon.
- Activation Code được presented cho User.
- Ứng dụng đếm ngược từ 60 giây đến 0. Đây là thời gian hết hạn của Coupon.
- Khi Coupon hết hạn, ứng dụng sẽ xóa Activation Code khỏi nó.
Quá trình này tương đối đơn giản từ góc độ User nhưng có thể rất khó triển khai trong ứng dụng. Hãy xem cách chúng ta có thể làm cho nó rõ ràng với Domain Driven Design.
More Business Rules for the Account
Trong Model, chúng ta đã có khả năng kiểm tra xem chúng ta có thể đổi Points thu thập được để lấy Coupon đã chọn hay không. Ngay bây giờ chúng ta cũng cần thực hiện một cuộc trao đổi thực tế. Chúng ta phải cập nhật Account và giảm số Points thu thập được.
data class Account(
val email: Email,
val collectedPoints: Points,
) {
fun canExchangePointsFor(coupon: Coupon): Boolean {
return collectedPoints >= coupon.points
}
fun exchangePointsFor(coupon: Coupon): Account {
check(canExchangePointsFor(coupon))
return copy(collectedPoints = collectedPoints - coupon.points)
}
}
Để làm cho nó hoạt động, chúng ta cũng đang cập nhật class Points
của mình bằng một toán tử minus
thích hợp.
@JvmInline
value class Points(val value: Int) : Comparable<Points> {
init {
check(value >= 0)
}
...
operator fun minus(other: Points): Points {
return Points(value - other.value)
}
}
Why do we copy our Entity?
Phương thức exchangePointsFor
trả về một bản sao của Account
. Ý tưởng chính của lập trình hướng đối tượng là kết hợp dữ liệu và hành vi với nhau trong các objects.
Các công nghệ khác, chủ yếu là backend, thường xác định Entities là mutable. CollectedPoints
field của chúng ta có thể là var
thay vì val
. Bằng cách này, phương thức ExchangePointsFor
chỉ có thể thay đổi giá trị của collectPoints
mà không tạo bất kỳ bản sao nào.
data class Account(
val email: Email,
// Now it is mutable
var collectedPoints: Points,
) {
fun canExchangePointsFor(coupon: Coupon): Boolean {
return collectedPoints >= coupon.points
}
fun exchangePointsFor(coupon: Coupon) {
check(canExchangePointsFor(coupon))
// We don't create copy but update the same instance
collectedPoints = collectedPoints - coupon.points
}
}
Trong các ứng dụng dành cho thiết bị di động, chúng ta muốn dựa vào immutable data. Immutable mang lại cho chúng ta sự tự tin và an toàn hơn khi làm việc với multiple threads, reactive streams , v.v. Nhưng sử dụng immutable data không có nghĩa là chúng ta không thể sử dụng phương pháp Domain Driven. Sự khác biệt duy nhất là thay vì thay đổi giá trị bên trong Entity, chúng ta tạo một bản sao có giá trị mới.
Activation Code
Trong phần mô tả chức năng này, chúng ta đã đề cập đến Activation Code. Trong trường hợp của chúng ta, nó không phải là Entity mà chỉ là Value có hai fields.
data class ActivationCode(val value: String, val remainingTime: Duration) {
init {
require(value.length == LENGTH)
}
val expired: Boolean
get() = remainingTime.inWholeSeconds <= 0
suspend fun waitForActivation(): ActivationCode {
check(!expired)
delay(1.seconds)
return copy(remainingTime = remainingTime - 1.seconds)
}
companion object {
const val LENGTH = 8
}
}
Nó chứa rất nhiều thông tin và rules hữu ích. Trước hết chúng ta biết rằng Activation Code phải có đúng 8 ký tự. Chúng ta cũng biết rằng nó hết hạn khi remainingTime
đạt đến 0. Cuối cùng, phương thức waitForActivation
thực hiện tích tắc một giây mà chúng ta sử dụng để đếm ngược.
Activation Code Factory
Việc tạo một instance của Entity hoặc Value đôi khi có thể phức tạp hơn và sử dụng hàm tạo không phải là lựa chọn tốt nhất. Trong codebase của chúng ta, chúng ta có thể giới thiệu các Factories có thể xử lý tác vụ này theo cách riêng biệt.
const val ALLOWED_CHARS = "1234567890QWERTYUIOPASDFGHJKLZXCVBNM"
const val REMAINING_SECONDS = 60
class ActivationCodeFactory @Inject constructor() {
fun createRandomActivationCode(): ActivationCode {
val code = buildString(ActivationCode.LENGTH) {
repeat(ActivationCode.LENGTH) {
append(ALLOWED_CHARS[Random.nextInt(ALLOWED_CHARS.length)])
}
}
return ActivationCode(value = code, remainingTime = REMAINING_SECONDS.seconds)
}
}
Coupon Business Rules
Sau khi có Activation Code, chúng ta có thể sử dụng mã đó để triển khai Business Rules cho Coupon.
data class Coupon(
val id: ID,
val name: Name,
val points: Points,
val image: URL,
val activationCode: ActivationCode?,
) {
val canBeActivated: Boolean
get() = activationCode != null && !activationCode.expired
fun collect(activationCode: ActivationCode) = copy(
activationCode = activationCode,
)
suspend fun waitForActivation() = copy(
activationCode = activationCode?.waitForActivation(),
)
fun reset() = copy(
activationCode = null,
)
}
Entity ban đầu của chúng ta giờ đã trở nên có ý nghĩa hơn nhiều. Chúng ta thấy rằng Coupon canBeActivated
miễn là nó có activationCode
và activation
này chưa expired
.
Chúng ta có thể thu thập Coupon bằng cách chuyển Activation Code cho nó. Sau đó, chúng ta chờ kích hoạt được xử lý bằng cách giảm remainingTime
bên trong Activation Coupon. Cuối cùng, chúng ta có thể đặt lại Coupon bằng cách xóa Activation Code khỏi nó.
Ladies and Gentleman, the Use Case
Và bây giờ phần tốt nhất đến. Chúng ta có thể kết hợp tất cả các business rules này với nhau để thực hiện một flow logic đầy đủ. Và flow này sẽ được điều phối bởi Use Case.
class CollectCouponUseCase @Inject constructor(
private val accountRepository: AccountRepository,
private val couponsRepository: CouponsRepository,
private val activationCodeFactory: ActivationCodeFactory,
private val scope: CoroutineScope,
) {
suspend operator fun invoke(couponId: ID) {
// Get necessary data
var account = checkNotNull(accountRepository.getLoggedInAccount().first())
var coupon = checkNotNull(couponsRepository.getCoupon(couponId).first())
// Take points from the account
account = account.exchangePointsFor(coupon)
// Collect the coupon using Activation Code
val activationCode = activationCodeFactory.createRandomActivationCode()
coupon = coupon.collect(activationCode)
// Save all the changes
accountRepository.saveLoggedInAccount(account)
couponsRepository.updateCoupon(coupon)
// Launch separate job to not block the UI with the countdown
scope.launch {
// Countdown until Coupon expiration
while (coupon.canBeActivated) {
coupon = coupon.waitForActivation()
couponsRepository.updateCoupon(coupon)
}
// Reset the Coupon to the initial state
coupon = coupon.reset()
couponsRepository.updateCoupon(coupon)
}
}
}
Và... Quy trình business phức tạp của chúng ta được đóng lại trong một Use Case. nhỏ gọn và đẹp mắt.
Cùng kết thúc nó
Chúng ta đã giải quyết nhiều vấn đề business khác nhau mà không cần chạm vào View Models của ứng dụng. Nếu bạn kiểm tra GitHub repository, bạn có thể tìm hiểu mức độ đơn giản của các View Models này. Tất cả là nhờ Domain Model có ý nghĩa xử lý tất cả các công việc hợp lý 🎉
Nếu bạn muốn dùng thử trong dự án của mình, hãy nhớ các quy tắc sau:
- Entities mô tả các khái niệm domain chính trong Model. Chúng có unique identity và vòng đời.
- Để quản lý vòng đời của một Entitiy, chúng ta sử dụng Repository hiển thị các hoạt động CRUD đơn giản.
- Values được sử dụng để mô tả các phần của Entities. Chúng không có identity và vòng đời và chúng không yêu cầu Repository.
- Khi việc tạo Entitiy hoặc Value phức tạp hơn, chúng ta có thể trích xuất sự phức tạp này sang một Factory.
- Model không chỉ chứa dữ liệu mà còn chứa các ràng buộc, rules và hành vi business.
- Use Cases chỉ điều phối các quy trình logic trong ứng dụng. Tất cả các quyết định và sửa đổi dữ liệu nên được ủy quyền cho Model.
- Nếu ứng dụng của bạn đơn giản và không có quy trình để điều phối thì có thể bạn không cần đến các Use Cases.
Nguồn: https://medium.com/itnext/domain-driven-android-building-a-model-which-makes-sense-badb774c606d
All rights reserved