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


處理完使用者的輸入後，就可以來著手實際的處理。通常會將這些實作放到 `app/` 目錄下，然而當我們要以一個 Domain（領域）來劃分時，使用 `lib/` 來放置這些實作可能更加恰當，未來要轉換框架或者搬移，就只需要移動 `lib/` 而不會和 Rails 綁定。

<!--more-->

## Command Object

就我個人而言，會喜歡將 UseCase 分為 Command（命令）和 Query（查詢）兩種類型，方便區分寫入、讀取兩種不同情境，如果未來有需要實現 [CQRS](https://martinfowler.com/bliki/CQRS.html) 的設計，也更容易延伸出去完善。

因為 Rails 預設不會載入 `lib/` 目錄的內容，我們可以透過調整 `config/application.rb` 來支援這個行為。

```ruby
# config/application.rb
module Example
  class Application < Rails::Application
    # ...
    config.autoload_lib(ignore: %w[assets tasks generators])
  end
end
```

這個方式雖然方便，然而我們希望以套件的型式存在時，自行加入 `require_relative` 來處理可能會更加恰當。

接著實作一個 `CreateRoute` 的 Command 在 `lib/shipments/command/create_route.rb`

```ruby
module Shipments
  module Command
    class AddRoute
      TIMEZONE_MAPPING = {
        'TPE' => 'Asia/Taipei',
        'OKA' => 'Asia/Tokyo'
      }.freeze

      attr_reader :timezone_repository

      def initialize(timezone_repository:)
        @timezone_repository = timezone_repository
      end

      def execute(shipment_id:, route_id:, date:, time:)
        timezone = to_timezone(route_id)
        delivered_at = timezone.parse("#{date} #{time}")

        [shipment_id, route_id, delivered_at]
      end

      private

      def to_timezone(route_id)
        name = TIMEZONE_MAPPING.fetch(route_id)
        timezone_repository.create(name)
      end
    end
  end
end
```

處理時間的部分跟前面實作的 Input Object 基本上是類似的，然而在這裡我們定義了一個 Timezone Repository 的介面，預期會有一個 `#create` 行為產生我們期望的時區物件，並且提供一個 `#parse` 方法讓我們針對時間解析。

因為沒有任何實質對某個特定類型（Class）的依賴，即使替換到其他框架，只要能實作對應的方法就能夠繼續使用這段程式碼。

##  調整 Controller {#adjust-controller}

原本我們是透過 Input Object 來取得新增的 Route 資訊，然而現在有 Command 初步做了一些事情，我們可以讓 Controller 改為依賴這個 Command 處理，也讓 Controller 更接近協調任務的角色。

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

    def create
      input = CreateRouteInput.new(*route_params)
      command = Shipments::Command::AddRoute.new(timezone_repository: ActiveSupport::TimeZone)
      shipment_id, route_id, delivered_at = command.execute(**input)

      render json: {
        id: 1,
        state: 'shipping',
        routes: [
          {
            route_id: 'OKA',
            shipment_id:,
            delivered_at: Time.utc(2024, 3, 19, 0, 0, 0)
          },
          {
            route_id:,
            shipment_id:,
            delivered_at: delivered_at.utc
          }
        ]
      }
    end

    # ...
  end
end
```

因為 `ActiveSupport::TimeZone` 剛好具備了 `.create` 方法，並且回傳的物件實例也有滿足 `#parse` 的要求，因此剛好符合我們對 Timezone Repository 的介面，就可以直接當作參考傳遞給 Command 使用。

另一方面，我們在 `#execute` 是使用 Keyword Argument 來接收資訊，因此可以讓 Input Object 直接轉換成 Hash 傳遞進去，最後將回傳結構傳遞給我們暫時性寫死的結果即可。

> 假設將 Input Object 規劃在 `lib/shipments/input` 的話，這邊可以改為直接傳遞 Input 的方式，因為 `lib/shipments` 在架構上會知道這個物件與相應的細節

## Input Object

接下來只需要修正我們的 Input Object 去除跟 Domain 相關（站點跟時區對應）的資訊，就可以讓這段程式碼正確運作起來。

```ruby
class CreateRouteInput
  attr_reader :route_id, :shipment_id, :date, :time

  def initialize(route_id, shipment_id, date, time)
    @route_id = route_id
    @shipment_id = shipment_id.to_i
    @date = date
    @time = time
  end

  def to_hash
    {
      route_id:,
      shipment_id:,
      date:,
      time:
    }
  end
end
```

目前的版本就更明顯是一個單純的物件，並且支援轉換成 Hash 的 Data Transfer Object（資料傳輸物件）

這樣的實作、開發方式確實相對繁瑣，因此並不是要一開始就這樣發展，可以選擇在後續重構時，慢慢的將 Controller、Model 中相關的邏輯抽離出來。然而，系統一開始就具備一定程度的複雜度時，直接採用這樣的方式會更加恰當。

接下來我們要繼續實作 Entity（實體）來完善一個完整的行為。

