Skip to content

Instantly share code, notes, and snippets.

@ingramchen
Last active February 23, 2023 03:36
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save ingramchen/17eecd8eed18a523458b73ec73ae699a to your computer and use it in GitHub Desktop.
Save ingramchen/17eecd8eed18a523458b73ec73ae699a to your computer and use it in GitHub Desktop.
Domain Model Layer Convention (Model 層級管理)

Model 層級管理

Domain Model 層的意義

一個傳統 Java Spring 的應用程式會分為三層 @Controller @Service @Repository,然後依據組織需求,再往下細分。 比方說 Service 層複雜的話,會增加 Gateway、Facade 層,或是我們組織獨有的 CoreService 層

另一個層級大部份 Java 組織會忽略的是 Domain Model 層,絕大多數 Java 開發者會開個 entity 或 model package,然後 將所有的 Entity 往裡面丟。然而該 Entity 只是一堆欄位和 getter/setter 的堆疊,也就是單純的 Table 的對應物件而已, 不具備任何 business logic

在我們組織裡,我們會遵從 Domain Driven Design (DDD) 的設計理念,具體的將 model 層提升為 domain model,也就是該層級 必需管理 business logic,讓這些 Entity 直實反應 domain 的需求,具備豐富的行為和能力 (Rich domain model), 而不是只是一堆欄位的組合物

Model 層物件的種類 (DDD)

- Entity

即有可獨立辨識的 id 的物件,通常如果該物件有對應的 Table,而 Table 有 primary key,那麼該物件十之八九是 Entity。這些物件只要 id 相同就是代表同個實體,其他欄位不同只是該實體在不同時間時的狀態而已。

在我們組織裡,由於 kotlin 與 mybatis 搭配的關係,多半會上一個 @NoArg 的 annotation。另外 id 的生成也會有 Snow flake 這個選項

- Value Object

純值物件。該物件只有值,沒有辨別的 id。只要值相同就視為同個物件。常見的有 Email、度量衡、還有 java.time 裡的所有時間相關的都是 value object。一般來說不會有自己的 Table,要存的話通常是在欄位上,但這個看正規化的程度而定。

分辨物件是否為 Entity 還是 Value Object ,完全視 Domain 的需求而定。例如地址一般來說是 Value Object,但如果你是開發郵局的需求,那麼那裡的地址可能會變成 Entity ,因為它們可能會需求追蹤地址的狀態變更。因此在 model 層級設立 class 這件事,不可避免一定會和 business logic 有關聯,不是單純的欄位堆疊。

注意 Dto 或是 POJO 都不能算是 Value Object,就算看起來很像。要成為 Value Object 是在 Domain 上有意義才行,你直接念 value object 的 class 名給 PM 聽他聽得懂的,但你念 XxxDto 給 PM 聽,他不曉得你在說什麼。

- Aggregate

一種 Entity 的聚合。例如 Order 和 OrderItem 都是 Entity 沒錯,但它們其實在 Domain 中有著不可分割的關係,也就是 OrderItem 不能獨立存在,也不能單獨的自由的變更。要異動 OrderItem,勢必 Order 也要跟著改變 (總價、折扣等運算都要重來)。如果在需求中發現 Entity 有這種關係時,請視為 Aggregate 處理,Order 在這裡扮演 Aggregate Root ,統一管理旗下的 Entity 的 CRUD 各種變更

辨別出 Aggregate 之後,第一個直接影響的就是 Repository,也就是只會有 Aggregate Root 才會有 Dao,旗下的 Entity 不準有自己的 Dao (因為它們不被允許獨立創建)。 Aggregate 這個概念往上的層級延伸的話,會形成 Bounded Context。不過這超出 Model 層,不再討論。

BTW, 回過頭來看 Order/OrderItem 一定是 Aggregate 嗎?試著想像如果需求變成團購的話,那麼這時訂單也許不再是 Aggregate Root 了。同樣的東西隨著 domain 不同,你的 modeling 也會不同。

Model 上需要具備運算的邏輯

對 model 資料的異動和生成,在對應的 Entity/Aggregate/Value Object 上,會有相對應的 method 來操作。那些 method 則直接對應的 domain 的需求和邏輯。按 DDD 的設計原則,你的 model 不該是只有欄位的空殼。

Model 層其他物件的種類 (組織專屬)

由於 model 層包含 business logic ,所以除了 DDD 定義三大類外,常見的還有

- Data Transfer Object

Dto,單純傳輸用的物件。通常是由 Entity 沿伸而來,它不會有任何 business logic,只會有格式轉換或簡單的 validation 而已

- Entry

承上,是一種 Dto。在我們組織裡將輸入類型的 Dto 額外分出一類叫 Entry (而 Dto 則只負責輸出)。Entry 上通常都有 validation,從 controller 層一路傳到 model 層

- 運算/演算法

通常是一些 parser,格式轉換等等純邏輯運算。在 kotlin 很多可以直接寫 function 就行,不見得一定要做個 class 來放

- 報表類物件

從 Entity/Value Object 統整,經過 SQL 運算而得的 projection。多用於報表,因為 query 後直接出給 client ,所以多半直接做成 Dto 使用

- View / Materalized View

資料庫為求效能優化和 query 簡化,而製作的中間產物,這些不一定在程式中會以 class 的方式的出現,很多直接藏在 Dao 層裡,Model 層完全看不到。但有些也是會應需求出現 class 放在 model 層

- JSON 物件 (Value Object)

進入 NoSQL 時代後,傳統 RDBM 正規化操作不在是唯一的解法,很多時候 Entity 的欄位開始會儲存成 JSON 格式。這些物件都是 Value Object 的一種。在我們組織裡大量採用 kotlin sealed class 的能力進行進階的 modeling,這類物件多半是 cfg 或是 body 結尾。

此類物件在我們組織裡還有另一個特徵,它會從 controller 直通到 database 的 JSONB 欄位,不會經過 Entry/Dto 等轉換。因此它上面也會出現只有 Entry 才有的 validation。

- FilterOption

我們組織常用的查詢條件的專用物件。一般 CRUD 的需求都會有個查詢的表單,可以填寫條件進入資料庫篩選,這需要一個 class 去承載這些資料,傳到 server 。因此它是一種輸入型的 Dto。這裡不使用 Entry 的原因是 FilterOption 不會 serialize 成 JSON Body,它會直接出現在 GET 的 request parameter 上。FilterOption 會直通 SQL,所以上面會有一些給 SQL 用的運算。另外,務必保護好各欄位的使用,不可受 SQL injection 攻擊

- Exception

我們組織採用簡化的錯誤處理機制 - 如果用戶做了錯誤的操作 (例如錢不夠,email 填錯等等),server 要返回 i18n 後的錯誤訊息時,service 可直接 throw DomainException。實作 DomainException 都要求做 i18n,這些 exception 在前端收到都是 status code 400,直接顯示 i18n 的訊息給用戶看。

當然 throw DomainException 這種流程一定會造成 transaction rollback,絕大多數的情況這是符合需求的。如果有特別的錯誤處理,但又不要 rollback,不要走 DomainException 這種途徑。

- Enum

通常是一些 status 或 type,跟隨在相關的 Entity 旁。有些很大的 Enum 可能會有自己的 package 和獨立檔案

- Tuple

採用 Functional Programming 去操作 model 物件時,多半修改完後會產生新的 copy。在更複雜的情況下,操作完後會產生很多 copy 和資訊。這種是常見的 return multiple value from method call 的需求,在 python 具備 tuple 語法的程式語言很容易辦到。然而在 Java/Kotlin 卻不行,只能製作一個 data class 替代。

目前 Tuple 類型的 class 在我們組織並沒有統一的結尾命名,不過一般會命名為 Change (異動)。例如 Poll.vote(): VoteChange 或是 Question.acceptAnswer(): AcceptChange

Tuple 物件嚴格來說是一種 Value Object,但它呼叫後就丟棄了,所以上面不該有邏輯,也不能出 service 層。它也不適合獨立放成一個檔案

- Dao

在我們組織裡,Dao 被放在 model 層,原因單純是 Entity 沒有 Dao 的話等於白寫,有強烈的不可分割性。另外我們組織的 Dao 雖然掛著 @Repository,但它兼任著 Factory 的責任。白話講就是 Dao 同時扮演 DDD 中的 Factory 和 Repository 的角色,跟一般常見 Java 專案不同。為何要兼任則是考量 (1) FP 的 side effect 管理與 (2) 物件導向中 Object Integrity -- 兩個因素後,融合而成的結晶。

無法理解的話就先死背吧。

Model 層 Kotlin 檔案分類

在 Java 中,一個 class 一定要一個檔案,因此檔案數量會激增,只能再增加更多 package 來管理。在其他語言則是無此限制,尤其是我們採用的 kotlin。在 kotlin 的慣例中檔案可以放多個 class 和 function,所以某方面來說一個檔案可以視為一個迷你的 package 來使用。

我們組織裡目前建議分檔法以資料庫的 Table 和 View 為依據來分,這樣比較有具體的規則來分類。而且單看該 package 時,那些檔案名稱直接對應著 E-R diagram 上的圖,很好推理和想像。

- Exception.kt

所有 DomainException subclass 統一放在一名為 Exception.kt 的檔案管理

- Entity Table

如果該 Entity class 有自己的 Table,那麼該 Entity 自成一個檔案。注意該 Entity 會環繞著與它相關的 Entry/FilterOption/Dto/Enum/Tuple 等 class,那些衛星 class 要跟該 Entity 放在同一個檔案內,不可自成一 kt 檔

- 獨立的 Value Object

獨立概念的 Value Object 自成一個檔案 (若有相關的可歸在同檔案內)。注意 Tuple 不能依這個分類法分。

- View / Materialized View

由於是以資料庫物件來分檔,因此如果有 model class 對應著獨立的資料庫中 view 的話,它也要抽出自成一個檔案。當然它的衛星 class 要跟它放在同一檔案

- JSON Value Object

cfg / body 等 sealed class 是大型 Value Object,它也代表著一個 schemeless 的 Table (它只是被濃縮在一個 P JSONB 欄位而已),所以要獨立成一檔

- Pseudo View (報表類)

有一些報表 class 是有成團關聯的,比方說某個廣告版位的點擊報表,它會有自己的 Dto 和 FilterOption,但是它的資料是其他 Table JOIN 與運算後的成果。如果仔細觀察那些 Query,它們很有可能被抽像成 DB 的 View 來操作,只是沒真的去建立而已。這類 class 泛稱為 Pseudo view ,多出現在報表的應用。可以選擇將它們抽成獨立一檔 (也可不抽)

- Dao

首先一個 Table 配一個獨立的 Dao 一定是錯的。而一個 model package 配單一個 Dao 則是對八成。 通常是先從單一 Dao class 開始,然後觀察新增的 Entity 是否和原來的 Entity 相距很遠,很遠很獨立的話,可以新增另一個 Dao class 來管理。注意如果發現 Entity 的關聯是 Aggregate 的話,它們只能在同個 Dao 內,不可拆開。

其他

其他無法用上述法則歸類的則自成一檔,不需再糾結。

/// 一個實際的 sealed class Value Object 範例,它會 serialize 到 postgres 的 JSONB 欄位
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, defaultImpl = FallbackGachaChainCfg::class)
@JsonSubTypes(
JsonSubTypes.Type(value = GachaChainCoinCfg::class, name = "GachaChainCoinCfg"),
JsonSubTypes.Type(value = GachaChainGemCfg::class, name = "GachaChainGemCfg"),
)
@JsonPropertyOrder(alphabetic = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
sealed class GachaChainCfg {
abstract fun requireCoin(chain: GachaChain): Int
abstract fun requireGem(chain: GachaChain): Int
}
object FallbackGachaChainCfg : GachaChainCfg() {
override fun requireCoin(chain: GachaChain): Int {
return MAX_COIN + 1
}
override fun requireGem(chain: GachaChain): Int {
return MAX_GEM + 1
}
}
data class GachaChainCoinCfg(
@get:Min(1)
@get:Max(MAX_COIN.toLong())
val singleChain: Int,
@get:Min(1)
@get:Max(MAX_COIN.toLong())
val tenChain: Int
) : GachaChainCfg() {
init {
require(singleChain >= 1 && singleChain <= MAX_COIN)
require(tenChain > singleChain && tenChain <= MAX_COIN)
}
override fun requireCoin(chain: GachaChain): Int {
return when (chain) {
GachaChain.SINGLE -> singleChain
GachaChain.TEN -> tenChain
}
}
override fun requireGem(chain: GachaChain): Int {
return 0
}
}
data class GachaChainGemCfg(
@get:Min(1)
@get:Max(MAX_GEM.toLong())
val singleChain: Int,
@get:Min(1)
@get:Max(MAX_GEM.toLong())
val tenChain: Int
) : GachaChainCfg() {
init {
require(singleChain >= 1 && singleChain <= MAX_GEM)
require(tenChain > singleChain && tenChain <= MAX_GEM)
}
override fun requireCoin(chain: GachaChain): Int {
return 0
}
override fun requireGem(chain: GachaChain): Int {
return when (chain) {
GachaChain.SINGLE -> singleChain
GachaChain.TEN -> tenChain
}
}
}
// Aggregate 的範例
//
// Poll 是投票,PollChoice 是其選項,不可分割。Poll 為 Aggregate Root
// 這裡有個 business logic 會員投票行為: `vote()`,裡面會驗證並完成投票的異動,
// 用 VoteChange 這個 Tuple 匯整結果
// MmemberVote 是另一個 Entity,代表會員投下的票
// PollAlreadyCloseException 則是 DomainException
@NoArg
data class Poll(
val title: String,
val content: String,
val maxMultipleSelection: Int,
val beginAt: Instant,
val endAt: Instant,
val voterCount: Long,
val choices: List<PollChoice>,
val status: PollStatus,
val id: Long = -1
) {
fun vote(
member: Member,
choiceIds: Set<PollChoiceId>,
existVote: MemberVote?,
now: Instant
): VoteChange {
require(choices.map { it.id }.containsAll(choiceIds)) {
"invalid choice ids: $choiceIds"
}
require(choiceIds.size <= maxMultipleSelection) {
"exceed max selections: $choiceIds"
}
if (!isOpen(now)) {
throw PollAlreadyCloseException()
}
val memberVote = existVote?.voted(choiceIds)
?: MemberVote(member.id, id, choiceIds, now)
val existChoiceIds = existVote?.choiceIds ?: emptyList()
val deletedChoiceIds = existChoiceIds.subtract(memberVote.choiceIds)
val createdChoiceIds = memberVote.choiceIds.subtract(existChoiceIds)
val voterIncrement = existChoiceIds.isEmpty() && memberVote.choiceIds.isNotEmpty()
val voterDecrement = existChoiceIds.isNotEmpty() && memberVote.choiceIds.isEmpty()
return VoteChange(
memberVote,
deletedChoiceIds,
createdChoiceIds,
voterIncrement,
voterDecrement,
firstTime = existVote == null
)
}
private fun isOpen(now: Instant) = now in beginAt..endAt && status == PollStatus.PUBLISHED
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment