---
title: "Repository - 重新思考 Rails 架構"
date: 2024-11-15T00:00:00+08:00
publishDate: 2024-11-15T00: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/15/rethink-rails-architecture-repository/"
language: "zh-tw"
---


當我們將運送模組（取 `module Shipments` 的方式稱呼）的實體（Entity）確立下來後，就可以來處理倉庫（Repository）也就是我們的資料如何保存的議題。在某些軟體開發的最佳實踐中，會建議推遲資料庫的設計，就是因為一但確定後就難以修改。

 依照這次的流程，我們在接近功能完成時才處理，能省去不少前期就確定資料表結構的問題。

<!--more-->

## Migration

透過前面的實作，我們基本上確定只需要有 Shipment Route 的資訊就足以構成現階段的 Shipment 實體，因此只需要加入一張 `shipment_routes` 資料表即可。

使用 `rails generate` 命令產生一個 `ShipmentRoute` Model 來提供這個機制。

```ruby
class CreateShipmentRoutes < ActiveRecord::Migration[7.1]
  def change
    create_table :shipment_routes, primary_key: [:shipment_id, :name]do |t|
      t.bigint :shipment_id
      t.string :name
      t.datetime :delivered_at

      t.timestamps
    end
  end
end
```

跟我們習慣直接使用 `id` 當作 Primary Key 不同的地方在於，我們使用 `shipment_id` 和 `name` 來作為主鍵，因為從前面的功能判斷這兩個組合是不會重疊的情境，因此適合用於這個狀況。

> 假設一開始就要決定，我們可能就會採用 `id` 搭配 `shipment_id` 和 `route_id` 的設計，在維護上可能就會變得更複雜一些。

## Repository

我們產生的 `ShipmentRoute` 基本上可以直接當作 Repository 使用，因此可以像這樣實作。

```ruby
class ShipmentRoute < ApplicationRecord
  scope :by_shipment_id, ->(shipment_id) { where(shipment_id: shipment_id).order(:delivered_at) }

  def self.save(shipment)
    routes = by_shipment_id(shipment.id)

    transaction do
      shipment.routes.each do |route|
        routes.find_or_create_by(name: route.id).tap do |shipment_route|
          shipment_route.update!(delivered_at: route.delivered_at)
        end
      end
    end
  end

  def self.find_shipment(shipment_id)
    routes = by_shipment_id(shipment_id)
    Shipments::Shipment.new(id: shipment_id).tap do |shipment|
      routes.each do |route|
        shipment.add_route(route_id: route.name, delivered_at: route.delivered_at.utc)
      end
    end
  end
end
```

這裡做了幾個處理，首先是用 `by_shipment_id` 的 Scope 來定義查詢的行為，接下來設計了 `.save` 和 `.find_shipment` 兩個方法，用於產生 `Shipment` 物件以及儲存修改。

在這個部分會跟以往我們認知到的 Model 差異很多，基本上不會去定義實例方法反而是 Class Method 更多，然而這個做法不一定能很好對應更複雜的情境，因此還可以繼續切分成獨立的 `Repository` 物件類型放到 `app/repositories` 中來維護。

> 這裡是用於相對簡單情境的案例，實際的專案還是會推薦切分出 Repository 會更好一些。

## 調整 Use Case {#adjust-usecase}

有了 Repository 後，原本我們在 Use Case 上的處理就會變得更加明確，也能很快地理解意圖。

```ruby
module Shipments
  module Command
    class AddRoute
      # ...

      attr_reader :timezone_repository, :shipment_repository

      def initialize(timezone_repository:, shipment_repository:)
        @timezone_repository = timezone_repository
        @shipment_repository = shipment_repository
      end

      def execute(shipment_id:, route_id:, date:, time:)
        shipment = shipment_repository.find_shipment(shipment_id)

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

        shipment_repository.save(shipment)
        shipment
      end

      # ...
    end
  end
end
```

上面的調整可以看到 `Shipment` 會透過具備 `#find_shipment` 方法的 Repository 來找生成對應的物件，在處理完畢後，呼叫 `#save` 方法來保存，這樣就不會依賴 Rails 的 ActiveRecord 只要能夠提供這兩個方法就可以運作。

因為回傳改變，我們也先對 Controller 做調整，因為 `Shipment` 物件不適合被 Controller 依賴，因此後續會再做調整。

```ruby
module Shipments
  class RoutesController < ApplicationController
    # ...

    def create
      input = CreateRouteInput.new(*route_params)
      command = Shipments::Command::AddRoute.new(
        timezone_repository: ActiveSupport::TimeZone,
        shipment_repository: ShipmentRoute,
      )
      shipment = command.execute(**input)

      render json: {
        id: shipment.id,
        state: shipment.state,
        routes: shipment.routes.map do |route|
          {
            route_id: route.id,
            shipment_id: shipment.id,
            delivered_at: route.delivered_at.utc,
          }
        end
      }
    end

    # ...
  end
end
```

## 更新測試 {#update-step-definition}

現在我們的測試會需要對應真實資料，是時候把一開始撰寫測試跳過的步驟定義（Step Definition）實作進去。

```ruby
# ...

Given('there have some shipment route') do |table|
  table.hashes.each do |row|
    row['name'] = row.delete('route_id')
    ShipmentRoute.create(row)
  end
end

# ...
```

因為我們在 Cucumber 是使用 `route_id` 來作為定義，因此需要針對這筆資料替換成儲存到資料庫的 `name` 就能順利運行。

也可以調整測試的描述，直接使用 `name` 就可以減少額外的處理，大多數情境我也會偏好這種方式讓 Cucumber 的步驟定義更簡單一些。

接下來就是針對 `Shipment` 物件回傳給 Controller 的處理，增加一個封裝避免實體直接暴露到外面。

