---
title: "領域事件在 Rails 中的呈現形式"
date: 2023-01-04T00:00:00+08:00
publishDate: 2023-01-04T00:00:00Z
lastmod: 2023-01-04T00:52:41+08:00
tags: ["Ruby","經驗","Domain-Driven Design","Domain Event","領域事件"]
toc: true
permalink: "https://blog.aotoki.me/posts/2023/01/04/the-domain-event-types-in-rails/"
language: "zh-tw"
---


最近在實作一個會員集點功能的時候意外發現很適合作為「領域事件（Domain Event）」的例子，在很多情況下其實不容易去描述這個概念，然而這個例子倒是很好的反應這個概念，以及 Rails 的特性所產生的變化。

<!--more-->

## 會員集點{#loyalty-point}

會員集點算是很多電商會有的功能大家可能都不陌生，然而實際上會有哪些資訊存在其中，以我的設計來看大致上有這些項目。

| 模型（Model）| 說明 |
|----------------|------|
| Reward | 發送獎勵的紀錄（發放者、數量）|
| LoyaltyCard | 會員卡，用來區別持有者 |
| LoyaltyEvent | 收到點數的紀錄 |

大致上來看，可以看出這是一個有著 Event Sourcing（事件溯源）的模型設計，然而我們不一定要採取這樣的方式，因為不在討論範圍因此就不細講。

如果用事件風暴（Event Storming）分析，大概可以看出這樣的過程

* 發放獎勵（`Command`）
* 確認獎勵（`System`，儲存到資料庫）
* 獎勵產生（`LoyaltyPointRewarded`）

當 `LoyaltyPointRewarded` 發生時，就要後續去產生 `LoyaltyEvent` 並且附加到某 `LoyaltyCard` 上，因為 `LoyaltyCard` 是 `LoyaltyEvent` 的聚合（Aggregate）所以會由 `LoyaltyCard` 來製作。

## 直接呼叫 {#directly-call}

在我的認知中，最直接的領域事件其實是直接呼叫對應的方法，如果只有一個物件關注事件是最直覺的。

因此，我們可以像這樣處理獎勵事件

```ruby
def create
  # Command (1)
  @form = RewardForm.new(params)

  # System (1)
  @reward = Reward.new(
    user: @form.user,
    merchant: @form.merchant,
    amount: @form.amount
  )

  # Domain Event (1)
  @reward.save!

  # Command (2)
  loyalty_card = LoyaltyCard.find_or_create!(user: @reward.user)

  # System (2)
  service = LoyaltyPointService.new(@reward.merchant)
  service.verify_balance!(@reward.amount)
  service.reward_to(loyalty_card, @reward.amount)
  # reward_to: (card, amount)-> { card.events.build(amount: amount) }

  # Domain Event (2)
  reward.transaction do
    reward.completed!
    loyalty_card.save!
  end
end
```

然而，這樣的呼叫其實有一些問題存在，第一個是 `Reward`（獎勵產生）跟 `LoyaltyCard` 會員卡的紀錄不一定是綁定的，因此這邊沒有用 `Transaction`（交易）機制綁定，如果後續的步驟失敗就會造成資料的不一致。

另一方面，在 `#create` 方法會有非常冗長的實作，因為我們同時處理了兩種[脈絡](https://blog.aotoki.me/posts/2022/12/16/write-better-program-by-add-the-context/)問題，一個是「預定發放點數」另一個是「記錄發放點數」如此一來可讀性就會下降。

## ActiveJob

這類問題，我們可以用 `ActiveJob` 這類背景作業的機制來解決，像是 `ActionController`、`ActionMailer`以及 `ActiveJob` 剛好就是對應 Domin-Driven Design 的應用層（Application Layer）或者 Clean Architecture 的使用案例（Use Case）的機制。

那麼，在直接呼叫版本中的「預定發放」跟「記錄發放」正好是 `ActionController`（使用者操作）和 `ActiveJob`（領域事件觸發）兩個不同使用案例的情境。

經過重構後，我們可以得到像這樣的版本：

```ruby
# app/controllers/rewards_controller.rb
def create
  @reward = Reward.new(reward_attributes)
  @reward.save!
end

def reward_attributes
  params.permit(:user_id, :merchant_id, :amount)
end
```

首先，我們只需要處理「預定發放」的機制因此只需要簡單的儲存即可，甚至不需要 Form Object 輔助直接利用 `ActiveRecord` 內建的 Validation 來檢驗。

接下來利用 `Callback` （回呼）機制，作為觸發事件的方式。

```ruby
# app/models/reward.rb

after_commit :commit_loyalty_point, on: :create

def commit_loyalty_point
  CommitLoyaltyPointJob.perform_later(self)
end
```

雖然我們可以直接在 `#commit_loyalty_point` 方法實作「記錄發放」的機制，然而這就破壞了 Entity（實體）類型的特性，也就是只管理自身狀態，同時也會讓我們的實作有副作用（Side Effect）產生，那麼去呼叫另一個「使用案例」來處理似乎更加合適。

```ruby
# app/jobs/commit_loyalty_point_job.rb

def perform(reward)
  return if reward.completed?

  loyalty_card = LoyaltyCard.find_or_create!(user: reward.user)

  service = LoyaltyPointService.new(reward.merchant)
  service.verify_balance!(reward.amount)
  service.reward_to(loyalty_card, reward.amount)

  reward.transaction do
    reward.completed!
    loyalty_card.save!
  end
end
```

如此一來我們就分離了兩個不同脈絡下的使用案例，而且因為 `ActiveJob` 的特性，我們還得以讓「發放點數」這件事情能夠被重試，也能夠透過增加更多的後設資料（Metadata）去關聯發放點數的事件來讓我們可以追蹤更多資訊。

## 領域事件{#domain-event}

在討論領域模型（Domain Model）的時候很難把領域事件放進去討論，畢竟他是一個抽象的概念，正因如此領域事件其實可以有很多種表現的形式。

以上面的例子，他會是一個 `Callback` 的形式，除此之外像是 `Webhook`、`PubSub` 等等都可以用來作為領域事件發佈的一種形式，也因此我們在思考「領域事件」的時候會需要事件風暴的結果來輔助，會更能清晰的思考「事件傳遞」是怎樣的形式。

另外，要注意的是領域事件的對應物件需要是 DTO（Data Transfer Object，資料傳輸物件）是不帶有脈絡的，在 Rails 中利用 [GlobalID](https://github.com/rails/globalid) 這種封裝，將 `Reward` 抽離脈絡變成 `gid://app/Reward/1` 這樣的形式，在回到 `ActiveJob` 時又以 `Reward.find(1)` 的方式還原回來，才讓我們省下了尋找 Entity 的處理，這也是 Rails 容易入門卻經常設計不佳的問題之一，因為太多語法糖的存在。

> 文章的例子是以我正在實作的系統作為例子，他的需求、規格造成了這樣的實現，並不一定代表所有的系統都是這樣運作，也許你正在維護的產品並不需要這樣處理，那就會有不一樣的實作出現。


