---
title: "Entity - 重新思考 Rails 架構"
date: 2024-11-08T00:00:00+08:00
publishDate: 2024-11-08T00:00:00+08:00
lastmod: 2024-06-02T17:03:47+08:00
tags: ["Rails","Domain-Driven Design","設計","Clean Architecture"]
series: "rethink-rails-architecture"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/11/08/rethink-rails-architecture-entity/"
language: "zh-tw"
---


到 Use Case 階段我們已經初步跟 Rails 框架做出區隔，建構出屬於我們的 Domain Model（領域模型）然而在 Use Case 中含有許多核心概念的處理並沒有被抽離出來，這些東西就是 Entity（實體）也就是整個領域中要處理的對象（Object）

<!--more-->

## Aggregate

在我們的設計中 Shipment（運送）會合併多個 Route（路徑）來構成，通常具備這種將一些可以獨立識別的物件整合起來的情況，我們會用 Aggregate（聚合）來看待。

```ruby
module Shipments
  class Shipment
    attr_reader :id, :state, :routes

    def initialize(id:)
      @id = id
      @state = State.new(:pending)
      @routes = []
    end

    def add_route(route_id:, delivered_at:)
      route = Route.new(id: route_id)
      raise ArgumentError, 'Route already delivered' if routes.include?(route)

      route.deliver(delivered_at)
      routes.push(route)
      shipping
    end

    def shipping
      return unless state == State.pending

      @state = State.shipping
    end

    def delivered
      return unless state == State.shipping

      @state = State.delivered
    end

    def eql?(other)
      id == other.id
    end
    alias == eql?
  end
end
```

上述的例子描述了 `Shipment` 物件能夠進行 `#add_route` 的操作，而且當新增路由時，會嘗試變更為 `shipping` 的狀態。

這段程式碼雖然簡單，但是很好的呈現出運送處理的意圖，不使用 Rails 的 Model 來做這件事情，其中一個原因是我們希望所有屬性都是 Read-only 的，或者說不能被 `Shipment` 以外的物件改變，來確不會有預期外的修改，然而 Rails 的 Model 則是將全部屬性都設計為可修改，就不符合我們的期待。

## Entity

基本上只要是以 ID 或者任何可以用於唯一識別當作比較條件的物件，都可以視為一種 Entity 類型，因此扮演 Aggregate 角色的 Shipment 或者其下的 Route 都可以當作一種 Entity 來看。

```ruby
module Shipments
  class Route
    attr_reader :id, :delivered_at

    def initialize(id:)
      @id = id
      @delivered_at = nil
    end

    def deliver(delivered_at)
      @delivered_at = delivered_at
    end

    def eql?(other)
      id == other.id
    end
    alias == eql?
  end
end
```

基本上使用純 Ruby 物件（PORO）來處理的用意，跟 `Shipment` 是相同的，我們希望利用物件的封裝來限制可用的行為，確保修改是可預期的。

> `Shipment` 和 `Route` 都實作了 `#==` 和 `#eql?` 方法，並且只會對 `id` 屬性比較來判斷是否為相同的物件

## Value Object

Value Object 在 Rails 中經常會看到，在使用上也沒有太大的差異。要注意的是這類物件都是不可變（Immutable）的形式存在，也因此我們可以透過比較內容來判斷是否為相同的數值。

```ruby
module Shipments
  class State
    ALLOWED_STATES = %i[pending shipping delivered].freeze

    class << self
      def pending
        new(:pending)
      end

      def shipping
        new(:shipping)
      end

      def delivered
        new(:delivered)
      end
    end

    attr_reader :state

    def initialize(state)
      raise ArgumentError, "Invalid state: #{state}" unless ALLOWED_STATES.include?(state)

      @state = state
    end

    def eql?(other)
      @state == other.state
    end
    alias == eql?

    def to_s
      @state.to_s
    end

    def as_json(*)
      to_s
    end
  end
end
```

在這裡提供了一些 Factory Method 方便使用，因此在 `Shipment` 物件中切換狀態時，可以用 `state == State.pending` 來判斷，要在更進一步讓意圖更加清楚的話，可以定義像是 `#shipping?` 的方法來替換掉 `state == State.pending` 的實作。

基本上 Entity 相關的實作，都是單純 Ruby 的定義，然而卻是整個系統中最核心的部分，也是我們一層一層提取出來的關鍵行為。

## 更新 Use Case {#update-use-case}

有了 Entity 後，我們就可以把 Use Case 調整，實作的內容會比上一個版本更加直覺，能夠一眼就看出 `Command::AddRoute` 想要做的處理。

```ruby
module Shipments
  module Command
    class AddRoute
      # ...
      def execute(shipment_id:, route_id:, date:, time:)
        shipment = Shipment.new(id: shipment_id)
        shipment.add_route(route_id: 'OKA', delivered_at: Time.utc(2024, 3, 19, 0, 0, 0))

        timezone = to_timezone(route_id)
        delivered_at = timezone.parse("#{date} #{time}")
        shipment.add_route(route_id:, delivered_at:)

        route = shipment.routes.last
        [shipment.id, route.id, route.delivered_at]
      end

      # ...
    end
  end
end
```

因為我們還沒有實作 Repository 用來還原狀態，因此先手動的將測試所需的狀態製作出來，後續的 `shipment.add_route(route_id:, delivered_at:)` 實作，就能讓人一眼看出 Add Route Command 內部是如何看待「增加運送節點」這一個概念。

下一階段我們會整合 Rails 的 Model 將 Repository 實作出來，以及討論 Shipments 資料表的必要性。

