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


在 Form Object 的階段有提過 Input Object 的概念，與之相對的則是 Output Object 的機制，這兩個物件也正好對應 Clean Architecture 中所提到的 Input Port 和 Output Port 的應用。

<!--more-->

## Output Object

Output Object 從定義上來說，可以很直覺的理解為 Use Case（使用案例）的回傳，即使我們像加入 Repository 後將 Entity 直接曝露給 Controller 也沒有問題。那麼為什麼還會有 Output Object 的需求存在呢？

現今大部分的軟體開發情境中，我們都會使用開發框架來協助開發，假設未來有需要更換框架（或者語言）就會遇到有大量的依賴（Dependency）在框架上的問題。為了對應這樣的狀況，合理的做法就是從 Use Case 作為基準跟框架切開，那麼 Use Case 就必須知道如何回傳結果，相比 Input Object 可以利用介面的方式定義，Output Object 就必定需要跟 Use Case 共存。

舉例來說，假設我們像 Form 的設計一樣使用了 `include ActiveModel::Model` 那麼在替換到其他框架（如：[Hanami.rb](https://hanamirb.org/)）就必須要加入 ActiveModel 的 Gem 才能使用，然而以 PORO（Plain Old Ruby Object）就能自由的替換，而保有原有的功能。

一般來說 Input / Output 兩種物件都是 Read-only 和 Serializable（可序列化）的特性。

```ruby
# lib/shipments/data/shipment.rb
module Shipments
  module Data
    Shipment = ::Data.define(:id, :state, :routes) do
      def eql?(other)
        id == other.id
      end
    end
  end
end

# lib/shipments/data/route.rb
module Shipments
  module Data
    Route = ::Data.define(:id, :delivered_at) do
      def eql?(other)
        id == other.id
      end
    end
  end
end
```

舉例來說，在 Ruby 3.2 就增加了 `Data` 類型物件，剛好可以滿足這樣的要求，或者我們直接使用 Ruby 的 `Hash` 也可以符合條件。

至於 Use Case 則針對實作調整，將原本的 Entity（實體）做一次轉換

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

      def execute(shipment_id:, route_id:, date:, time:)
        # ...
        build_shipment_output(shipment)
      end

      private

      # ...

      def build_shipment_output(shipment)
        routes = shipment.routes.map do |route|
          Data::Route.new(id: route.id, delivered_at: route.delivered_at)
        end

        Data::Shipment.new(id: shipment.id, state: shipment.state, routes:)
      end
    end
  end
end
```

雖然這樣的設計相當繁瑣，然而也能夠避免在傳遞到 Controller 還被修改或者操作的可能性，就能將相關的邏輯盡可能的集中在 Use Case 上，讓設計更明確。

## Rendering

在 Controller 收到這個物件後，我們可直接的呈現出來，然而會發現測試是失敗的。

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

    def create
		# ...
		render json: shipment
    end

	# ...
  end
end
```

這是因為我們預期的呈現跟實際的呈現有差異，這類情況通常就會由 Presenter（表現）類型的物件處理，至於 Rails 使用 View 的機制來實現基本上就非常足夠。

將原本的 `render json: shipment` 先替換為 `@shipment = command.execute(**input)` 的版本，讓 Controller 可以把 `@shipment` 傳遞給 View。

因為這次預設是 JSON 的內容，將 `config/routes.rb` 的內容也做調整。

```ruby
Rails.application.routes.draw do
  # ...
  resources :shipments, module: :shipments do
    resources :routes, only: [:create], defaults: { format: :json }
  end
end
```

最後，就可以利用 `jbuilder` 來做呈現上的處理。

```ruby
# app/view/shipments/routes/create.json.jbuilder
json.id @shipment.id
json.state @shipment.state
json.routes @shipment.routes do |route|
  json.route_id route.id
  json.delivered_at route.delivered_at.utc
  json.shipment_id @shipment.id
end
```

跟直接對 Output 做 Serialize 處理不同的地方在 `routes` 中是使用 `route_id` 以及多了一個 `shipment_id` 欄位，假設實務上沒有這樣的需求存在，可以統一後會更加簡單。

這一系列的實作可以觀察到，假設整體系統不複雜的情境下，是沒有必樣做出這樣的拆分，然而隨系統變得複雜後，將職責跟任務明確劃分就會自然出現這類物件，並不一定要刻意進行或者一次性的實踐，只要避免低階元件（Low-Level Component）被高階元件（High-Level Component）依賴的情況（如 `lib/` 下的實作使用了 `app/` 的物件，就違反原則）

