蒼時弦也
蒼時弦也
資深軟體工程師
發表於
這篇文章是 重新思考 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 是相同的,我們希望利用物件的封裝來限制可用的行為,確保修改是可預期的。

ShipmentRoute 都實作了 #==#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 資料表的必要性。