# 2025/04/27(Draft) 戰術設計: 常用模式: Entity, Value Object, Aggregate, Aggregate Root, Domain Servive, Factory, Anti-Corruption Layer, Repository 戰略設計是必須的:全局視角。關注領域模型之間的整體協作 根據業務邊界提出"限界上下文",而限界上下文之間的協作,提出"上下文映射&子域" =>核心是理解業務知識 =>戰略三大主題:上下文/精煉/大型結構 貧血 & 充血 模型: 貧血模型 => Service腫脹 => Rails 充血模型 => 區分業務職責 ***DDD不是取代物件導向,而是基於物件導向更近一步將模型分配到對應的邊界中,並定義不同邊界的模型與協作方式,使其交互過程清晰,依賴關係耦合更少*** --- 貧血模型:有屬性沒有行為的模型(業務封裝邏輯:只提供業務數據容器,並不發生業務行為=>都讓service做就好了...) 這會讓service散落在各個角落,而且某個業務邏輯會重複出現在不同的service object之中。 service業務處理外還要處理外部服務,交互... 太胖了! 優點: 1. 關注點分離:每一層注重自己的職責 2. 復用性強 3. 前期開發效率高(適合MVP) 缺點: 1. 底層缺乏抽象 2. 業務邏輯分散 3. 難以持續演化 ![截圖 2025-04-27 晚上11.10.27](https://hackmd.io/_uploads/rkAeKas1ge.png) --- 充血模型:有屬性又有行為 service不需要了解業務,將業務邏輯與基礎設施操作分離。(Rails應該不容易達成,因為model包含find_by or create ... 這很難額外封裝) 1. User interface: 不包含任何業務邏輯處理,僅暴露應用層服務,這裡程式碼應該相對簡單 2. Application:不包含業務邏輯,接受用戶請求後,通過基礎設施層加載領域模型(聚合根),然後交由領域模型完成業務操作,最後由基礎設施層持久化領域模型 3. Domain: 對業務進行領域建模的結果,包含所有領域模型 ex:Entity, Value Object和領域服務(行為) 4. Infrastructure: 實踐領域層定義的基礎設施接口,不包含業務邏輯。輸出和輸入應該是領域模型或者基礎數據類型 --- Hexagonal Architecture ![image](https://hackmd.io/_uploads/B1CGU6oyel.png) ![image](https://hackmd.io/_uploads/BJfELpoJle.png) ... # 2025/04/28 Command Object的資料包裝物件來代表"意圖"而非"動作"(可以加以驗證,但不可以有商業邏輯) Struct # 2025/04/29 Factory ```ruby= # app/domain/factories/article_factory.rb module Domain module Factories class ArticleFactory # 從 Command 或 DTO 建立 ArticleEntity def self.build_from_command(cmd) title = ArticleTitle.new(cmd.title) # Value Object content = ArticleContent.new(cmd.content) ArticleEntity.new( id: ArticleId.new, # 產生新的 ID title: title, content: content, metadata: Metadata.defaults # 一些預設資訊 ) end end end end ``` 但我感覺rails不需要經過這流程,因為這樣的話只是複雜化,而多一層封裝而已,而且物件創立其實不太會寫測試 --- Repository提供save方法,來達成數據持久化 => rails物件本身不用再次封裝,除非save流程牽涉到多種物件 => aggrate才會需要? => 想到的例子是event內的repeatable event=> 但專案內定義是form object? 某種程度上好像把商業邏輯也寫進了form object內=>難怪挺髒的?=>可以考慮用aggrate和Repository重構看看? --- 在 Rails 裡實踐 DDD 事件驅動(Domain-Driven Event-Driven)的大致流程,就是: 1. **在領域層(Entity/Aggregate)定義並產生事件** 2. **在應用層(Application Service/Repository)發佈事件** 3. **在基礎設施層註冊訂閱者(Subscriber)並處理事件** 以下分別以三種常見作法示範: --- ## 1. 用 ActiveSupport::Notifications ### 1.1 定義並發佈事件 ```ruby # app/services/article_application_service.rb class ArticleApplicationService def publish(id) ActiveRecord::Base.transaction do article = Article.find(id) article.publish! # 領域行為,會更新狀態、時間 article.save! # 事件發佈 ActiveSupport::Notifications.instrument( 'article.published', # 事件名稱 id: article.id, title: article.title ) end end end ``` ### 1.2 在啟動時訂閱 ```ruby # config/initializers/article_events.rb ActiveSupport::Notifications.subscribe('article.published') do |name, start, finish, id, payload| # payload = { id: ..., title: ... } SearchIndexingJob.perform_later(payload[:id]) NotificationMailer.article_published(payload[:id]).deliver_later end ``` - **優點**:不需要額外 gem,Rails 原生支援; - **注意**:需在 initializer 或 Rails.application.configure 裡訂閱一次。 --- ## 2. 用 Wisper(輕量的 Pub/Sub Gem) ### 2.1 安裝並設定 ```ruby # Gemfile gem 'wisper' # app/models/article.rb class Article < ApplicationRecord include Wisper::Publisher def publish! raise 'Already published' if published_at update!(published_at: Time.current) broadcast(:article_published, self) # 發佈事件 end end ``` ### 2.2 實作訂閱者 ```ruby # app/subscribers/article_subscriber.rb class ArticleSubscriber def article_published(article) SearchIndexingJob.perform_later(article.id) NotificationMailer.article_published(article.id).deliver_later end end ``` ### 2.3 在 ApplicationService 註冊並呼叫 ```ruby # app/services/article_application_service.rb class ArticleApplicationService def initialize @repo = Article # 將 subscriber 綁到 Article model Wisper.connect(ArticleSubscriber.new, async: true) end def publish(id) article = @repo.find(id) article.publish! # 會自動呼叫 subscriber end end ``` - **優點**:乾淨的發/訂閱 API、內建非同步選項; - **注意**:若重複 connect,要確保只註冊一次。 --- ## 3. 用專門的事件存儲庫(RailsEventStore) 當你需要完整的事件溯源(Event Sourcing)或複雜的流程管理(Saga/ProcessManager)時,可以引入 [RailsEventStore](https://github.com/RailsEventStore/rails_event_store): ### 3.1 定義事件 ```ruby # app/events/article_published.rb class ArticlePublished < RailsEventStore::Event # attributes: metadata、data end ``` ### 3.2 在 Service 內發佈 ```ruby class ArticleApplicationService def initialize(event_store: Rails.configuration.event_store) @event_store = event_store end def publish(id) ActiveRecord::Base.transaction do article = Article.find(id) article.publish! article.save! event = ArticlePublished.new(data: { id: id, title: article.title }) @event_store.publish(event, stream_name: "Article$#{id}") end end end ``` ### 3.3 註冊訂閱器 ```ruby # config/initializers/rails_event_store.rb Rails.configuration.event_store.tap do |store| store.subscribe(SearchIndexingHandler.new, to: [ArticlePublished]) store.subscribe(ArticleNotificationHandler.new, to: [ArticlePublished]) end ``` - **優點**:內建事件儲存、重放、安全的事務邊界; - **適用**:需要完整事件歷史、回溯、複雜 Saga 流程。 --- ### 小結 1. **產生事件(在 Entity 或 Service)** → 2. **發佈事件(Event Bus)** → 3. **訂閱事件(Subscriber / Handler)** → 4. **執行副作用(索引、通知、外部 API)** 2. Rails 原生可以用 **ActiveSupport::Notifications**;輕量級可用 **Wisper**;需要完整事件溯源時可用 **RailsEventStore**。 3. 關鍵在於:**把「狀態更新」與「副作用處理」分離**,讓你的領域模型只專注在業務規則上,副作用邏輯統一由訂閱者處理。 這樣就能在 Rails 裡,以 DDD 事件驅動的方式清晰地管理領域對象的狀態變化與後續動作! 想看看pub/sub怎應用 --- application layer會有command/query/veiw類型對象 => command object/ query object/ decorator object --- # 2025/05/05 在 DDD 架構中,`Proc`/`Lambda` 最常被用來「封裝可替換的行為」,讓業務邏輯更易組合、測試與擴充。以下三個典型範例說明它們如何落地: --- ## 1. Specification(規格模式) 將複雜的條件判斷抽成可重用、可組合的規格(Specification),每個規格都用一支 `->`(Lambda)實作: ```ruby # app/domain/specifications/specification.rb class Specification def initialize(&predicate) @predicate = predicate end def satisfied_by?(entity) @predicate.call(entity) end def and(other) Specification.new { |e| satisfied_by?(e) && other.satisfied_by?(e) } end def or(other) Specification.new { |e| satisfied_by?(e) || other.satisfied_by?(e) } end end # 使用範例 active_spec = Specification.new(&->(user){ user.active? }) adult_spec = Specification.new(&->(user){ user.age >= 18 }) adult_active_spec = active_spec.and(adult_spec) # 驗證 if adult_active_spec.satisfied_by?(current_user) # …允許後續操作 end ``` * **好處**:把「活躍使用者」或「成人使用者」的規則獨立成小單元,可任意組合(`and`/`or`),也能針對某個規格寫專門的單元測試。 --- ## 2. Strategy(策略模式)/Domain Service 當某段業務邏輯需要不同演算法時,可以把演算法注入成 `Proc`/`Lambda`: ```ruby # app/domain/services/price_calculator.rb class PriceCalculator def initialize(strategy) @strategy = strategy end # items: [{ price: 100, qty: 2 }, …] def calculate(items) @strategy.call(items) end end # 不同策略 standard = ->(items){ items.sum { |i| i[:price] * i[:qty] } } holiday = ->(items){ standard.call(items) * 0.9 } # 打 9 折 volume_off = ->(items){ total = standard.call(items) total > 1000 ? total * 0.85 : total } # 在 ApplicationService 或 Handler 裡: calc = PriceCalculator.new(holiday) order_total = calc.calculate(cart.items) ``` * **好處**:把「計價邏輯」與「呼叫端」分離,隨時可增減/切換策略,而不用動到核心程式。 --- ## 3. Domain Events(領域事件)處理 使用 `Proc`/`Lambda` 當成事件訂閱者(Subscriber),收到事件後一行程式就能執行: ```ruby # app/domain/events/event_publisher.rb module EventPublisher @subscribers = Hash.new { |h,k| h[k] = [] } def self.subscribe(event_name, &handler) @subscribers[event_name] << handler end def self.publish(event_name, payload) @subscribers[event_name].each { |h| h.call(payload) } end end # 在初始化階段或某個配置檔: EventPublisher.subscribe(:order_created) do |order| SendOrderConfirmationEmail.call(order.id) end EventPublisher.subscribe(:order_created) do |order| InventoryService.reserve_items(order.id) end # 在 CommandHandler 達成訂單建立後觸發: EventPublisher.publish(:order_created, order) ``` * **好處**:對事件的反應邏輯可以「隨時新增/移除」而不侵入核心流程,也方便測試單一事件處理。 --- ### Proc vs Lambda 小提醒 * **`lambda`**:參數檢查嚴格、`return` 只跳出 lambda 區塊。 * **`Proc.new`/`proc`**:參數可少傳、`return` 會試圖回到定義它的方法、比較危險。 在 DDD 中,推薦用 `->(...) { ... }` 或 `lambda`,明確把行為當「獨立函式」注入。 --- 透過這些模式,`Proc`/`Lambda` 就成為 DDD 架構下,「把行為當作資料」流動/組合的利器,既保持程式碼乾淨,也增強了彈性和測試性。 # 2025/05/06 --- ## 整体调用链示意 假设我们注册了两层 Middleware: ```ruby config.middleware.use Logging config.middleware.use Authentication ``` 在 Rails 初始化后,底层会把它们串成一个链条,最终接到你应用的最“终点”——Rails 路由与 Controller。大致结构是: ``` [ Web Server ] │ ▼ Logging#call(env) │ (在内呼叫 Authentication) ▼ Authentication#call(env) │ (在内呼叫 Rails App 本身) ▼ Rails Router → Controller#action ``` 当一个 HTTP 请求进来时,会依照上面的顺序,一层层地执行 `call(env)`,并把 `env` 继续往 “下一层” 传。 --- ## 详细执行步骤 以一次 GET `/orders` 请求为例,伪代码跟踪如下: 1. **Web Server 接受请求** * 比如 Puma 收到一个 GET `/orders`,把请求环境封装到一个 `env` Hash。 * 调用最外层 Middleware: ```ruby response = Logging.new( next_app ).call(env) ``` * 这里 `next_app` 就是下一环,也就是 `Authentication` 的实例。 2. **第一层:Logging#call(env)** ```ruby class Logging def initialize(app) @app = app end def call(env) Rails.logger.info "Received #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" @app.call(env) # <──① 将请求「往下一层」传递 end end ``` * **做了什么?** * 先打日志:`"Received GET /orders"` * 然后用 `@app.call(env)` 把请求交给它的下家(也就是 `Authentication`) * **为什么能往下?** * 在 `initialize` 的时候,Rails 框架已经把 `app` 设为下一个 middleware 的实例。 3. **第二层:Authentication#call(env)** ```ruby class Authentication def initialize(app) @app = app end def call(env) if env["HTTP_AUTH_TOKEN"] == "secret" @app.call(env) # <──② 通过认证后,再次「往下一层」传给 Rails 核心 else [401, {"Content-Type"=>"text/plain"}, ["Unauthorized"]] end end end ``` * **做了什么?** * 检查 header 里的 `AUTH_TOKEN` * 如果合法,就继续往下,不合法就直接回 401,后面的 Controller 根本不会被触发 * **继续传递?** * 同样是调用 `@app.call(env)`,`@app` 这次就是 Rails 应用自身,包括路由解析、Controller action。 4. **Rails 核心路由与 Controller** * Rails 收到从 Authentication 传过来的 `env`,启动路由匹配,找到 `OrdersController#index`: ```ruby class OrdersController < ApplicationController def index orders = Order.where(user_id: current_user.id) render json: orders end end ``` * 最终返回一个 `[status, headers, body]` 三元组给上层。 5. **响应沿链条回传** * Rails 返回的三元组直接交给 `Authentication#call` 调用处作为返回值,它再把这个返回值交给 `Logging`,最后到 Web Server,再发给客户端。 --- ## 为什么「解耦」? * **呼叫者(Web Server)**:完全不知道哪一层会处理认证、打日志,或最终调用哪个 Controller,只管把请求传给最外层 Middleware。 * **每一层 Middleware**: * **只关注自己的事**(Logging:打日志;Authentication:验 token) * **通过 `@app.call(env)` 决定是否往下传**(可以中途拦截、修改响应,或者直接 short-circuit) * **Controller**:更不需要关心认证或日志,假设进来就是一个“已经验证过”的请求,只专注在业务逻辑上。 --- ### 拓展思考 * 如果要在所有 Controller 前做 “限流”/“风控”,只要写一个新的 Middleware,插到链的合适位置即可,无需修改 Controller 任何代码。 * 这就是责任链(Chain of Responsibility)在 Rails 中最典型、最天然的落地:「每一层决定要不要把请求继续往下传」,并且相互独立,达到解耦的效果。 * **解釋責任鏈在領域服務中的使用** 在 DDD 分層架構裡,責任鏈(Chain of Responsibility)本身是一種領域行為(Domain Behavior),不應該散落在應用層(Application Layer)或塞進實體/值對象裡──而是要集中到領域層的一個\*\*領域服務(Domain Service)\*\*中,負責『組裝』和『執行』這條鏈』。 --- ## 為什麼這麼做? | 層級 | 責任 | 不當放置的問題 | | -------- | ---------------------------------------------------------- | ------------------------------------------------------- | | **應用層** | 用例流程 orchestration:接收命令、呼叫 Domain Service、處理事務、回傳結果 | 如果在這裡維護責任鏈,就會把業務細節混到用例流程中,造成 ApplicationService 臃腫、難以重用 | | **領域模型** | 實體(Entity)、值對象(Value Object)、聚合根(Aggregate)– 保持單一職責:狀態與不變式 | 如果把鏈式邏輯塞進實體或值對象,就會把多個獨立行為耦合在一起,違反「單一職責」且測試困難 | | **領域服務** | 處理「一個以上實體/聚合」的跨聚合行為,或無法歸屬到某個實體的複雜運作 | **正確的落點**:在這裡把各個 Handler/Step 組合成一條鏈,並執行它。封裝所有業務邏輯流程。 | --- ## 範例:訂單處理的責任鏈放在領域服務中 ```ruby # app/domain/services/order_processing_service.rb class OrderProcessingService def initialize(chain = default_chain) @chain = chain end # 用例層或 Controller 只需呼叫這個方法 def call(request) @chain.call(request) end private # 在這裡組裝整條責任鏈 def default_chain ValidateStock.new( ProcessPayment.new( CreateRecord.new( SendConfirmationEmail.new ) ) ) end end ``` * **ValidateStock**、**ProcessPayment**、**CreateRecord**、**SendConfirmationEmail** 都是各自只做一件事的 Handler。 * `OrderProcessingService` 負責「把它們串在一起」及「啟動執行」。 * **呼叫端**(ApplicationService、Controller、Job)只要: ```ruby OrderProcessingService.new.call(order_request) ``` 完全不用知道具體有哪些步驟,也不用管理 transaction 或錯誤處理細節。 --- ## 結論 > **在 DDD 裡,領域服務是負責協調多個聚合/實體或實作跨聚合的行為的合適場所。** > > 責任鏈作為一連串「有序執行的領域步驟」,它的**組裝邏輯**與**執行邏輯**都屬於領域行為,理應放在領域層的領域服務中,而不是分散在應用層或混進某個實體裡。 這樣不但能保持各層的**單一職責**、讓程式碼更乾淨,也方便未來**增減步驟**、撰寫**聚焦測試**,以及在不同用例間重複利用同一條責任鏈。 --- # 2025/05/12 以下是一種「先抓商業邏輯、後做資料庫設計」的可行流程,結合 DDD/Hexagonal 架構與 TDD 思路,在 Rails 專案中也能落地: ## 1. 定義領域模型與行為(Domain-First) 1. **蒐集用例** 以「故事 (User Story)」或「領域事件」為單位,寫下清楚的場景敘述: > 「當使用者送出訂單時,系統須計算總金額、檢查庫存、保留貨品,並最終標記訂單為 `confirmed`。」 2. **抽象出聚合與實體** * 以故事中出現的名詞(訂單 order、項目 line\_item、庫存 stock)當「實體/值物件」。 * 以故事中出現的動詞(計算金額、檢查庫存、確認訂單)當「方法/行為」。 3. **撰寫純 Ruby 物件** 在 `app/domain/…` 下,先建立「純邏輯」的類別,不碰 ActiveRecord: ```ruby # app/domain/order.rb class Order attr_reader :id, :items, :status def initialize(id:, items:) @id = id @items = items # array of LineItem @status = :pending end def total_amount items.sum { |li| li.price * li.quantity } end def confirm!(stock_checker:) raise "庫存不足" unless stock_checker.available?(items) stock_checker.reserve!(items) @status = :confirmed end end ``` 4. **為領域物件寫測試** 完全針對「Order#total\_amount」、「Order#confirm!」寫單元測試。 ```ruby describe Order do let(:items) { [LineItem.new(price:100,q:2), …] } subject { Order.new(id:1, items: items) } it "計算總價" do expect(subject.total_amount).to eq 200 end it "confirm! 時會呼叫 stock_checker" do checker = double(available?: true, reserve!: true) subject.confirm!(stock_checker: checker) expect(subject.status).to eq :confirmed end end ``` --- ## 2. 定義 Repository 介面 因為我們還沒做 DB,就用「介面」先寫好要的方法: ```ruby # app/domain/order_repository.rb class OrderRepository def find(id); raise NotImplementedError; end def save(order); raise NotImplementedError; end end ``` > **註**:呼叫端(Application Service)只依賴這個抽象,不關心底層怎麼存。 --- ## 3. 撰寫 Application Service(Use Case) 把故事流程承接起來、協調聚合與倉儲(Repository): ```ruby # app/application/services/place_order.rb class PlaceOrder def initialize(order_repo:, stock_checker:) @repo = order_repo @stock_checker = stock_checker end def call(user_id:, items:) order = Order.new(id: SecureRandom.uuid, items: items) order.confirm!(stock_checker: @stock_checker) @repo.save(order) order end end ``` 也為它加上對應的測試,保證「用例流程」符合商業需求。 --- ## 4. 設計資料庫(Migrations) 當所有領域邏輯、介面與測試都跑綠了,就把重點放到 DB: ```bash rails g migration CreateOrders id:uuid total_amount:decimal status:string rails g migration CreateLineItems order_id:uuid price:decimal quantity:integer ``` > **註**:欄位可依照 `order.total_amount`、`order.status`、`LineItem` 屬性補齊。 --- ## 5. 實作 Repository Adapter(Infra Layer) 將 ActiveRecord 與你的領域模型做映射: ```ruby # app/models/order_record.rb # ActiveRecord 版 class OrderRecord < ApplicationRecord has_many :line_item_records end # app/repositories/order_repository_impl.rb class OrderRepositoryImpl < OrderRepository def find(id) rec = OrderRecord.find(id) items = rec.line_item_records.map do |li| LineItem.new(price: li.price, quantity: li.quantity) end Order.new(id: rec.id, items: items).tap { |o| o.status = rec.status.to_sym } end def save(order) rec = OrderRecord.find_or_initialize_by(id: order.id) rec.total_amount = order.total_amount rec.status = order.status.to_s rec.save! # 同步 line items... end end ``` --- ## 6. 整合並發佈 在初始化 Application Service 時,注入具體的 Repository 與 StockChecker: ```ruby repo = OrderRepositoryImpl.new checker = RealStockChecker.new PlaceOrder.new(order_repo: repo, stock_checker: checker) .call(user_id: ..., items: [...]) ``` 這樣,你就完成了: 1. **先確保商業邏輯乾淨、測試充足** 2. **再設計 DB 架構與欄位** 3. **最後把 persistence 納入架構** --- ### 這樣做的好處 * **Domain-First**:所有核心規則都經過專注測試,跟資料庫無關,乾淨易測。 * **高內聚低耦合**:領域物件、用例服務、Repository 介面、Infra 層各司其職。 * **彈性強**:以後要改成別的儲存(NoSQL、外部 API),只需實作另一個 RepositoryImpl,不改 Domain/Use Case。 透過以上流程,就能在 Rails 專案中,真正做到「從商業邏輯出發、最後再做資料庫設計」的 DDD 實踐。 # 2025/05/14 在策略模式裡,**Context** 就像是一個「中介者/管家」,它只暴露給外部一個統一的方法,背後根據注入的策略(Strategy)決定真正要做什麼演算法。Context 的意義在於: 1. **隱藏細節**:使用者(或 Controller)不需要知道有哪些策略,只呼叫 Context 的同一個方法即可。 2. **統一介面**:不管背後換了哪一個策略,呼叫方式都一樣。 3. **動態切換**:在執行時注入不同策略,就能改變行為,而不用改 Context 或呼叫端程式。 --- ## 更真實的範例:運費計算 想像一個電子商務平台,需要根據使用者選擇的運送方式(標準、快遞、海外)來計算費用,但業務要求這些演算法要能獨立維護、測試,且未來再加新方式不用動到既有程式。 ### 1. 定義策略介面與具體策略 ```ruby # app/domain/shipping_strategy.rb class ShippingStrategy # 統一介面:所有策略都要實作 calculate 方法 def calculate(weight:, distance:) raise NotImplementedError end end # app/domain/standard_shipping.rb class StandardShipping < ShippingStrategy def calculate(weight:, distance:) # 標準運費:NT$50 + 每公斤 NT$10 + 每公里 NT$1 50 + weight * 10 + distance * 1 end end # app/domain/express_shipping.rb class ExpressShipping < ShippingStrategy def calculate(weight:, distance:) # 快遞運費:NT$100 + 每公斤 NT$20 + 每公里 NT$2 100 + weight * 20 + distance * 2 end end # app/domain/international_shipping.rb class InternationalShipping < ShippingStrategy def calculate(weight:, distance:) # 國際運費:NT$200 + 每公斤 NT$50 + 每公里 NT$5 200 + weight * 50 + distance * 5 end end ``` ### 2. 實作 Context:ShippingCostCalculator ```ruby # app/services/shipping_cost_calculator.rb class ShippingCostCalculator def initialize(strategy) @strategy = strategy end # 統一的計算方法,呼叫者只需知道「計算運費」,不必關心細節 def calculate(weight:, distance:) @strategy.calculate(weight: weight, distance: distance) end end ``` ### 3. 在 Controller 動態切換策略 ```ruby class OrdersController < ApplicationController def create # 從前端參數決定策略 strategy = case params[:shipping_method] when 'standard' then StandardShipping.new when 'express' then ExpressShipping.new when 'international' then InternationalShipping.new else raise "不支援的運送方式" end calculator = ShippingCostCalculator.new(strategy) cost = calculator.calculate( weight: params[:weight].to_f, distance: params[:distance].to_i ) render json: { shipping_cost: cost } end end ``` --- ### 為何看得出 Context 的意義? 1. **呼叫端(Controller)只跟 Context 互動** ```ruby calculator = ShippingCostCalculator.new(chosen_strategy) cost = calculator.calculate(...) ``` 不會出現 `if … elsif …` 把演算法寫在 Controller 裡,也不需要重複計算邏輯。 2. **要新增新運費策略,只要** * 新增一個 `class NewShipping < ShippingStrategy` 並實作 `calculate` * 在 Controller 的 `case` 或在更進階做法的「配置檔」裡注入即可 不用碰到 `ShippingCostCalculator` 或其他已存在的策略。 3. **統一測試 Context** ```ruby it "用標準運費計算" do calc = ShippingCostCalculator.new(StandardShipping.new) expect(calc.calculate(weight: 2, distance: 10)).to eq(50 + 2*10 + 10*1) end ``` 你也可以獨立測試每個策略: ```ruby it "ExpressShipping 打 100 + 2*20 + 10*2" do … end ``` --- ### 小結 * **Context(ShippingCostCalculator)** 的角色就是「給外部一個固定門面」,讓你隨時替換背後的策略(Strategy),卻不用改呼叫它的程式。 * 它隱藏了「到底是標準、快遞、還是國際運費算法」的細節,只留下「計算運費」這件事。 * 當你在專案裡,發現某段邏輯有多種演算法,且想把它們分開維護、動態切換、統一測試,就非常適合把它們抽成策略,再用 Context 來包一層統一介面。 --- 在 DDD 的分層裡,通常把程式碼分成: 1. **Domain Layer(領域層)** * 核心商業規則,包含 Entity、Value Object、Domain Service、Specification、Policy… 2. **Application Layer(應用/用例層)** * 用例(Use Case)或 Application Service,負責協調領域物件去完成一次「業務流程」(transactional orchestration),但不含太多商業規則本身。 3. **Infrastructure Layer(基礎建設層)** * 實作 Repository、第三方 API、Queue、ORM 等技術細節。 4. **UI Layer(界面層)** * Controller、Presenter、View… --- ### 對照 Shipping 範例 ```ruby # 抽象策略──定義「計算運費」這件核心規則 class ShippingStrategy def calculate(weight:, distance:); raise; end end # 各種演算法──純商業邏輯 class StandardShipping < ShippingStrategy; def calculate(...) … end; end class ExpressShipping < ShippingStrategy; def calculate(...) … end; end class InternationalShipping < ShippingStrategy; def calculate(...) … end; end # Context/Calculator──也可以看作是 Domain Service class ShippingCostCalculator def initialize(strategy); @strategy = strategy; end def calculate(weight:, distance:); @strategy.calculate(weight: weight, distance: distance); end end ``` * **StandardShipping、ExpressShipping、InternationalShipping** 這些都屬於 **Domain Layer** 的「商業邏輯」,即 Domain Service(或 Policy Object)。它們實作了不同的「運費計算規則」。 * **ShippingCostCalculator** 因為它也是針對「運費計算」這個領域概念,完全不依賴 ORM、外部資源,純粹執行商業行為,你也可以把它當成一個 **Domain Service**。它負責「把策略(算法)串起來、執行計算」。 --- ### 那麼 Use Case(應用層)長什麼樣? Use Case 層的職責是「接收外部請求、協調一或多個 Domain Service/Entity、處理事務邊界、回傳結果」。舉例,若我們要設計一個「查詢某訂單的運費」的用例,就會長成下面這樣: ```ruby # app/application/use_cases/calculate_order_shipping_cost.rb class CalculateOrderShippingCost def initialize(order_repo:, shipping_strategy_selector:) @repo = order_repo @selector = shipping_strategy_selector end def call(order_id:) order = @repo.find(order_id) # 根據訂單屬性(國內/國際;快速/普通)選策略 strategy = @selector.select_for(order) calculator = ShippingCostCalculator.new(strategy) cost = calculator.calculate(weight: order.weight, distance: order.distance) Result.new(cost: cost, order_id: order.id) end end ``` * `.find`、`.select_for`、`ShippingCostCalculator` 這些都在 **Domain Layer**,屬於商業邏輯/Domain Service。 * 但把它們「串在一起」的 `CalculateOrderShippingCost` 就是 **Application Layer**(一個用例)。它負責: 1. 載入資料(Repository) 2. 挑策略(可能是另一個 Selector Service) 3. 執行計算(呼叫 Domain Service) 4. 處理交易邊界(如果需要) 5. 回傳結果(DTO or Value Object) --- ### 總結 * **策略(Strategy)與 Calculator**:都是 **Domain Service**,它們承載真正的「商業規則/算法」。 * **用例(Use Case)**:在 **Application Layer**,它只負責「把各種 Domain Service、Repository、Entity 組合在一起」,形成一次完整的業務流程(例:計算運費、下訂單、付款流程…)。 所以,如果你問「ShippingStrategy 那些 class,是屬於商業邏輯還是 Use Case?」── * **它們主要是商業邏輯(Domain Service)**。 * **真正的 Use Case**,是那個在 `app/application/use_cases` 下、把策略注入、呼叫、並回傳結果的 Service。 --- 在 Clean Architecture(或一般 DDD 分層)裡: 1. **Entities(Enterprise Business Rules/Domain Layer)** * 封裝「核心不變式」與「單一行為」──也就是純粹的領域商業規則(enterprise business rules)。 * 例如:`Order#total_amount`、`Address#valid?`、`Money#add`… * 這裡的「商業邏輯」是你說的「每一個行為」。 2. **Use Cases(Application Business Rules/Application Layer)** * 負責「把多個行為串起來」,實現具體的用例流程(application business rules)。 * 這邊會協調 Entities、Domain Services、Repository、外部介面,並處理事務邊界、權限檢查等。 * 例如:`PlaceOrder` 用例會呼叫 `Order.new`、`order.confirm!`、`OrderRepository.save`,最後回傳結果。 換句話說: * **Domain(Entities+Domain Services)**:真正在定義「這個系統裡,什麼才叫做合法的『訂單』、什麼叫做合法的『金額計算』」──屬於整個企業/產品線通用的規則。 * **Application(Use Cases)**:則是「在這個應用場景下,我們要如何把那些規則組合起來,完成一次『下訂單流程』或『計算運費流程』」,包含呼叫時機、交易(transaction)範圍、例外處理和回傳格式。 --- ### 以下單為例 ```ruby # 【Entities / Domain Service】 class Order def total_amount; …; end # 單一行為 def confirm!(stock_checker); …; end # 單一行為 + 不變式 # 【Use Case / Application Service】 class PlaceOrder def call(user_id:, items:) order = Order.new(user_id:, items:) order.confirm!(stock_checker) repository.save(order) send_confirmation_email(order) order end end ``` * `Order#confirm!` 是「單一行為」→ **Enterprise rule** * `PlaceOrder#call` 是「把多個行為按順序執行」→ **Application rule** --- **結論**: > – 你說的「商業邏輯是每一個行為」——對應到 Entities/Domain Service(enterprise business rules)。 > – 「Use Case 是將每個行為組合而成不同的結果」——對應到 Use Cases(application business rules)。 --- # 2025/05/18 下面以「後台訂單篩選」為例,示範如何在 Rails 專案中,把規約模式(Specification Pattern)落地成一個可複用、可組合的過濾機制。 ## 1. 場景描述 * 管理員想在後台訂單列表頁,依照「訂單狀態」與「訂單金額範圍」做篩選: 1. 只看已**確認**(`status: 'confirmed'`)的訂單 2. 只看「總金額 ≥ NT\$1,000」的訂單 * 篩選條件可以任意組合: * 只篩確認狀態 * 只篩高金額 * 同時篩「確認且高金額」 * 全部訂單(沒套任何規約) --- ## 2. 定義規約介面與具體規約 ```ruby # app/domain/specifications/specification.rb class Specification # 抽象方法:檢驗單一物件,或轉成 AR scope def satisfied_by?(candidate) raise NotImplementedError end def to_scope(relation) raise NotImplementedError end # 組合:回傳新的 AndSpecification def and(other) AndSpecification.new(self, other) end end # app/domain/specifications/and_specification.rb class AndSpecification < Specification def initialize(left, right) @left, @right = left, right end def satisfied_by?(candidate) @left.satisfied_by?(candidate) && @right.satisfied_by?(candidate) end def to_scope(relation) # 先套左邊,再套右邊 @right.to_scope(@left.to_scope(relation)) end end ``` ```ruby # app/domain/specifications/confirmed_order_specification.rb class ConfirmedOrderSpecification < Specification def satisfied_by?(order) order.status == 'confirmed' end def to_scope(relation) relation.where(status: 'confirmed') end end # app/domain/specifications/high_value_order_specification.rb class HighValueOrderSpecification < Specification def initialize(min_amount = 1_000) @min = min_amount end def satisfied_by?(order) order.total_amount >= @min end def to_scope(relation) relation.where('total_amount >= ?', @min) end end ``` --- ## 3. 在 Controller 中使用 ```ruby class Admin::OrdersController < Admin::BaseController def index spec = build_specification # 用規約來產生最終的 ActiveRecord::Relation @orders = spec.to_scope(Order.all).order(created_at: :desc).page(params[:page]) end private def build_specification specs = [] specs << ConfirmedOrderSpecification.new if params[:status] == 'confirmed' specs << HighValueOrderSpecification.new(params[:min_amount].to_i) if params[:min_amount].present? # 如果沒條件,就回傳「全域匹配」物件 return AllSpecification.new if specs.empty? # 將多個規約合併成一條責任鏈 specs.reduce { |chain, spec| chain.and(spec) } end end ``` ```ruby # app/domain/specifications/all_specification.rb class AllSpecification < Specification def satisfied_by?(_); true; end def to_scope(relation); relation; end end ``` --- ## 4. 測試範例 ```ruby describe ConfirmedOrderSpecification do let!(:confirmed) { create(:order, status: 'confirmed') } let!(:pending) { create(:order, status: 'pending') } it "only matches confirmed" do spec = ConfirmedOrderSpecification.new expect(spec.satisfied_by?(confirmed)).to be true expect(spec.satisfied_by?(pending)).to be false expect(spec.to_scope(Order.all)).to include(confirmed) expect(spec.to_scope(Order.all)).not_to include(pending) end end describe "複合規約" do let!(:o1) { create(:order, status: 'confirmed', total_amount: 500) } let!(:o2) { create(:order, status: 'pending', total_amount: 2000) } let!(:o3) { create(:order, status: 'confirmed', total_amount: 2000) } it "confirmed AND high value" do spec = ConfirmedOrderSpecification.new .and(HighValueOrderSpecification.new(1000)) expect(spec.satisfied_by?(o1)).to be false expect(spec.satisfied_by?(o2)).to be false expect(spec.satisfied_by?(o3)).to be true expect(spec.to_scope(Order.all)).to contain_exactly(o3) end end ``` --- ## 5. 優點回顧 * **可組合**:隨時用 `and`、日後也能實作 `.or`、`.not` * **復用性**:同樣的 `ConfirmedOrderSpecification` 不只用在後台,也能在其他地方重複使用 * **測試容易**:單獨驗證各個規約,也能驗證複合邏輯 * **清晰分層**:Domain Layer 專注定義「什麼叫做已確認」、「什麼叫做高價值」,Controller 只負責組裝規約並套用