Entity - 重新思考 Rails 架構
到 Use Case 階段我們已經初步跟 Rails 框架做出區隔,建構出屬於我們的 Domain Model(領域模型)然而在 Use Case 中含有許多核心概念的處理並沒有被抽離出來,這些東西就是 Entity(實體)也就是整個領域中要處理的對象(Object)
Aggregate
在我們的設計中 Shipment(運送)會合併多個 Route(路徑)來構成,通常具備這種將一些可以獨立識別的物件整合起來的情況,我們會用 Aggregate(聚合)來看待。
1module Shipments
2 class Shipment
3 attr_reader :id, :state, :routes
4
5 def initialize(id:)
6 @id = id
7 @state = State.new(:pending)
8 @routes = []
9 end
10
11 def add_route(route_id:, delivered_at:)
12 route = Route.new(id: route_id)
13 raise ArgumentError, 'Route already delivered' if routes.include?(route)
14
15 route.deliver(delivered_at)
16 routes.push(route)
17 shipping
18 end
19
20 def shipping
21 return unless state == State.pending
22
23 @state = State.shipping
24 end
25
26 def delivered
27 return unless state == State.shipping
28
29 @state = State.delivered
30 end
31
32 def eql?(other)
33 id == other.id
34 end
35 alias == eql?
36 end
37end
上述的例子描述了 Shipment
物件能夠進行 #add_route
的操作,而且當新增路由時,會嘗試變更為 shipping
的狀態。
這段程式碼雖然簡單,但是很好的呈現出運送處理的意圖,不使用 Rails 的 Model 來做這件事情,其中一個原因是我們希望所有屬性都是 Read-only 的,或者說不能被 Shipment
以外的物件改變,來確不會有預期外的修改,然而 Rails 的 Model 則是將全部屬性都設計為可修改,就不符合我們的期待。
Entity
基本上只要是以 ID 或者任何可以用於唯一識別當作比較條件的物件,都可以視為一種 Entity 類型,因此扮演 Aggregate 角色的 Shipment 或者其下的 Route 都可以當作一種 Entity 來看。
1module Shipments
2 class Route
3 attr_reader :id, :delivered_at
4
5 def initialize(id:)
6 @id = id
7 @delivered_at = nil
8 end
9
10 def deliver(delivered_at)
11 @delivered_at = delivered_at
12 end
13
14 def eql?(other)
15 id == other.id
16 end
17 alias == eql?
18 end
19end
基本上使用純 Ruby 物件(PORO)來處理的用意,跟 Shipment
是相同的,我們希望利用物件的封裝來限制可用的行為,確保修改是可預期的。
Shipment
和Route
都實作了#==
和#eql?
方法,並且只會對id
屬性比較來判斷是否為相同的物件
Value Object
Value Object 在 Rails 中經常會看到,在使用上也沒有太大的差異。要注意的是這類物件都是不可變(Immutable)的形式存在,也因此我們可以透過比較內容來判斷是否為相同的數值。
1module Shipments
2 class State
3 ALLOWED_STATES = %i[pending shipping delivered].freeze
4
5 class << self
6 def pending
7 new(:pending)
8 end
9
10 def shipping
11 new(:shipping)
12 end
13
14 def delivered
15 new(:delivered)
16 end
17 end
18
19 attr_reader :state
20
21 def initialize(state)
22 raise ArgumentError, "Invalid state: #{state}" unless ALLOWED_STATES.include?(state)
23
24 @state = state
25 end
26
27 def eql?(other)
28 @state == other.state
29 end
30 alias == eql?
31
32 def to_s
33 @state.to_s
34 end
35
36 def as_json(*)
37 to_s
38 end
39 end
40end
在這裡提供了一些 Factory Method 方便使用,因此在 Shipment
物件中切換狀態時,可以用 state == State.pending
來判斷,要在更進一步讓意圖更加清楚的話,可以定義像是 #shipping?
的方法來替換掉 state == State.pending
的實作。
基本上 Entity 相關的實作,都是單純 Ruby 的定義,然而卻是整個系統中最核心的部分,也是我們一層一層提取出來的關鍵行為。
更新 Use Case
有了 Entity 後,我們就可以把 Use Case 調整,實作的內容會比上一個版本更加直覺,能夠一眼就看出 Command::AddRoute
想要做的處理。
1module Shipments
2 module Command
3 class AddRoute
4 # ...
5 def execute(shipment_id:, route_id:, date:, time:)
6 shipment = Shipment.new(id: shipment_id)
7 shipment.add_route(route_id: 'OKA', delivered_at: Time.utc(2024, 3, 19, 0, 0, 0))
8
9 timezone = to_timezone(route_id)
10 delivered_at = timezone.parse("#{date} #{time}")
11 shipment.add_route(route_id:, delivered_at:)
12
13 route = shipment.routes.last
14 [shipment.id, route.id, route.delivered_at]
15 end
16
17 # ...
18 end
19 end
20end
因為我們還沒有實作 Repository 用來還原狀態,因此先手動的將測試所需的狀態製作出來,後續的 shipment.add_route(route_id:, delivered_at:)
實作,就能讓人一眼看出 Add Route Command 內部是如何看待「增加運送節點」這一個概念。
下一階段我們會整合 Rails 的 Model 將 Repository 實作出來,以及討論 Shipments 資料表的必要性。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 軟體架構的挑戰 - 重新思考 Rails 架構
- 資料驅動設計 - 重新思考 Rails 架構
- 複雜的操作 - 重新思考 Rails 架構
- 時區換算 - 重新思考 Rails 架構
- 報表機制 - 重新思考 Rails 架構
- 通用化功能 - 重新思考 Rails 架構
- ActiveRecord 的限制 - 重新思考 Rails 架構
- 領域驅動設計 - 重新思考 Rails 架構
- 從架構到設計 - 重新思考 Rails 架構
- 重復使用的反思 - 重新思考 Rails 架構
- 釐清脈絡 - 重新思考 Rails 架構
- 劃分邊界 - 重新思考 Rails 架構
- 職責劃分 - 重新思考 Rails 架構
- 架構規劃 - 重新思考 Rails 架構
- 驗收測試 - 重新思考 Rails 架構
- Controller - 重新思考 Rails 架構
- Form - 重新思考 Rails 架構
- Use Case - 重新思考 Rails 架構
- Entity - 重新思考 Rails 架構