---
title: "自然地在 Rails 中應用 Data Context Interaction"
date: 2022-10-28T00:00:00+08:00
publishDate: 2022-10-28T00:00:00Z
lastmod: 2022-10-25T14:14:27+08:00
tags: ["Ruby","經驗","Domain-Driven Design","Data Context Interaction"]
toc: true
permalink: "https://blog.aotoki.me/posts/2022/10/28/use-data-context-interaction-natively-in-rails/"
language: "zh-tw"
---



之前在查資料時，在 Twitter 上看到 DHH 對 DCI (Data Context Interaction) 的[看法](https://twitter.com/dhh/status/280738513522155521)同時上週的[我對 Domain-Driven Design 的理解（2022 年）](https://blog.aotoki.me/posts/2022/10/21/my-understand-of-domain-driven-design/)這篇文章也提到了 DDD 跟 DCI 不互斥，而且能夠改善某些設計上的問題。

<!--more-->

## MVC 的限制{#limitation-of-mvc}

現今大多數的網路服務都是基於 Model-View-Controller 這樣的架構下去設計的，同時框架本身提供了 ORM（Object–relational mapping）的機制，也因此我們會很直覺的認為資料表跟模型（Model）物件是一個直接的映射關係。

也因此，在使用像是 Rails 這類框架時，我們大多會像這樣使用。

```ruby
class WalletController < ApplicationController
  # ...
  def deposit
    @wallet = current_user.wallets.find_by(currency: params[:currency])
    @wallet.balance += params[:amount].to_d
    @wallet.save

    redirect_to @wallet
  end
end
```

單就這段程式碼來看，其實沒有什麼大問題，不過有經驗的人其實很清楚現實狀況非常複雜，因此不太可能這麼單純。

## DCI 的目的{#the-target-of-dci}

簡單來說 DCI 是針對 MVC 的補充，用來更清晰的釐清當下的情境，也因此是一個關於資料（Data）在怎樣的情境（Context）能做出怎樣的互動（Interaction）的使用場景。

我們回到上一個段落的 `deposit`（儲值）的情境來看，整個情境是這樣的：

* Data - 某個貨幣的錢包餘額
* Context - 儲值的狀況
* Interaction - 儲值

把他們跟 MVC 關聯起來，則會是：

* Data - `Wallet` Model 的資料
* Context - `Wallet` Controller
* Interaction - `deposit` 的處理

在這裡會發現一個有點「奇怪」的狀況，那就是我們的情境為什麼是 `Wallet` 呢？是不是應該有個 `DepositContext` 來處理比較合理？

就這件事情來看，我認爲沒有「正確」的答案，假設 `Wallet` 相關的處理都很單純，那麼我們直接把他視為一個 Context 來看待是完全沒有問題的，或者說理想上能保持這樣的狀態我們的應用會更單純。

如果過於複雜，以往我們會拆分 Service Object 來解決，就這點來說也沒有問題，因為他可以是 Domain Service 而且 Service 的層級上來說比 Context 還小一點的。

## 還能做什麼？{#what-can-do}

照上面的解說來看，我們似乎沒有事情可以做了？這樣子 DCI 的用意在哪裡，當我們使用 MVC 的時候不就已經符合條件了嗎？

我們回到前面仔細觀察這兩行，就會發現似乎有哪裡不對勁：

```ruby
@wallet.balance += params[:amount].to_d
@wallet.save
```

在這兩行裡面，我們並沒有描述「互動」是怎樣的，也就是說這個 `Wallet` 物件的互動是直接把 `balance` 屬性拿出來修改，並沒有給定「互動」的定義。

因此，更合理的方式應該是：

```ruby
@wallet.deposit(params[:amount].to_d)
```

我們需要明確的描述這個物件「能做的事情」也就是 `#deposit` 方法，也因此這個 DCI 的敘述是「有一個 Wallet 在處理 Wallet 的情況下可以被儲值（Deposit）」進而構成了對情境的完整敘述，那麼我們在 Ruby 最常使用的 BDD（行為驅動，Behavior-Driven Development）測試框架 RSpec 就能夠自然的「對應」這件事情。

```ruby
RSpec.describe WalletController, type: :controller do
  describe 'POST /deposit' do
    # ...
    context 'when currency is USD' do
      # ...
      it { expect { deposit }.to change(wallet, :balance) }
      it { is_expected.to redirect_to wallet_path(wallet) }
    end
  end
end
```

像這樣子，我們可以用 `describe` 描述一個場景，接下來就可以針對這個情境用 `context` 給予不同的資訊，來驗證這個場景下所發生的各種事情，進而很自然的就能將 Controller 和測試對應起來。

## Concern 與角色{#concern-and-role}

當我們將大量的方法集中到一個 Model 上，構成充血模型（Rich Model）後就面臨了另一個問題——物件過於巨大難以管理，這也是以往 Rails 提倡 Fat Model 後面臨的問題，也因此才會有許多專案選擇用 Service Object 來抽離這些邏輯。

然而，一個物件的狀態（或稱屬性）應該要由物件本身自己來管理，也就是所謂的「高內聚」的狀態，當我們將一部分的行為抽離出來後反而讓這個狀態流失，甚至要為了維持狀態操作正常，而將一些本應該是私有的行為曝露出來。

然而在 Ruby 語言本身就具備了 Mixin（混和）的性質，在這樣的前提下 Rails 採用了 Concern（相關）的機制來解決這個問題。

假設我們的模型中有數種類似 `Wallet` 而且可以被「儲值」的情況，我們就可以將它獨立成一個模組。

```ruby
module CanDeposit
  extend ActiveSupport::Concern

  included do
    # DSL ...
  end

  def deposit(amount)
    self.balance += amount
  end
end

class Wallet
  include CanDeposit
end

class Account
  include CanDeposit
end
```

如此一來，我們利用了 Mixin 的特性將「可以儲值」的性質混合到數種不同的物件上，在 DCI 的觀點中，有一些文章會用角色（Role）來描述這樣的關係，在這個例子中我們就是賦予了 `Wallet` 和 `Account` 具備「儲值對象」這一個角色，並且給予了 `deposit`（儲值）這一個互動選項。

那麼回到我們在 Rails 負責的 Context 的 Controller 上，這段程式具備的意涵就完全不同。

```ruby
class WalletController < ApplicationController
  # ...
  def deposit
    @wallet = current_user.wallets.find_by(currency: params[:currency])
    @wallet.deposit(params[:amount].to_d)
    @wallet.save

    redirect_to @wallet
  end
end
```

我們可以明確的知道在「Wallet 的情境下要進行 `deposit` 時，會有一個 Wallet 的資料參與（扮演 `CanDeposit` 的角色）因此可以對其執行 `deposit` 的操作」

如果我們還想更近一步，加入 DDD 在建模上以「領域（Domain）」為基礎的方式實作，讓整個場景更佳「完善」則可以加入像是 Value Object（值物件）的實作來更近一步的描述現實世界的情境。

```ruby
class WalletController < ApplicationController
  # ...
  def deposit
    @amount = Money.from_cents(params[:amount], params[:currency])
    @wallet = Wallet.from_user(current_user).find_by(currency: @amount.currency)
    @wallet.deposit(@amount)
    @wallet.save

    redirect_to @wallet
  end
end
```

樣我們就可以明確地表達出這個情境中的資訊有金錢的單位、幣種（`Money.from_cents`）以及錢包（`Wallet.from_user.find_by`）互動是儲值（`deposit`）等等資訊，就自然而然的構成了一個不需要註解也能清晰描述情境的實作。

> Rails 的 Concern 機制是方便使用 Rails 的 DSL 所設計，假設沒有需要使用 DSL 的話並不一定要加入 `extend ActiveSupport::Concern` 在內。

