ドメインモデル設計の基本パターン
概要
この記事は、現場で役立つシステム設計の原則〜変更を楽で安全にするオブジェクト指向の実践技法を読み学習した内容を Ruby on Rails のコードに例を変換してまとめ直したものです。
また、書籍では Java のコードを例にしており、Ruby on Rails の慣習とは一部異なる部分があるので、その部分はオミットした内容となっています。
この記事では、ドメインモデル設計の実際の業務における基本パターンを実例を交えて解説しています。設計における基本的な考え方とアプローチについては前回の記事を参照してください。
業務の関心事の基本パターンを覚える
ドメインモデルでの開発においてもトランザクションスクリプトは発生しやすい
トランザクションスクリプト
if 文の入れ子構造となったコードのこと
オブジェクト指向で業務ロジックを整理しても、外部イベントに応答する入り口になるアプリケーション層のクラス(Rails のコントローラーに相当)には if 文を使用した業務的な判断ロジックが増えがちとなる。
これは開発の過程では新規のルールを判断するロジックを持つドメインオブジェクトが不足しており、それらを追加・修正するより if 文を追加する方が手っ取り早いケースが多いためである。
この結果として、アプリケーション層のクラスに if 文が増殖し、if 文の入れ子構造となった手続き型におけるトランザクションスクリプトの状態となってしまう。
トランザクションスクリプトを防ぐためには
オブジェクト指向設計でトランザクションスクリプトに陥ることを防ぐためには、ドメインオブジェクトの基本的な設計パターンを覚えて、プログラムがスッキリ書けるイメージを持つことが重要である。
基本的な設計パターンによる実装経験を積めば、似たようなケースを見たらすぐに実装すべきドメインオブジェクトが頭に浮かぶようになり、手が動くようになる。
ドメインオブジェクトの基本パターン
通常の業務アプリケーションで覚えるべきドメインオブジェクトの設計パターンは以下の通り。(これらの設計パターンについては別記事を参照)
ドメインオブジェクト | 設計パターン |
---|---|
値オブジェクト | 数値、日付、文字列をラッピングしてロジックを整理 |
コレクションオブジェクト | 配列やコレクションをラッピングしてロジックを整理 |
区分オブジェクト | 区分の定義と区分ごとのロジックを整理する |
列挙型の集合操作 | 状態遷移ルールなどを列挙型の集合として整理する |
ドメインオブジェクトを組み合わせて 4 つの関心ごとのパターンを表現する
基本設計パターン 4 種類のドメインオブジェクトを覚えたら、それらを組み合わせて以下の 4 つの関心ごとのパターンに業務ロジックを分類して整理していくと、業務ロジックの大半が、アプリケーション層ではなく、ドメインモデルに自然に集まるようになる。
関心事のパターン | 業務ロジックの内容 |
---|---|
口座(Account)パターン | 現在の値(現在高)を表現し、妥当性を管理する |
期日(DueDate)パターン | 約束の期日と判断を表現する |
方針(Policy)パターン | 様々なルールが複合する、複雑な業務ロジックを表現する |
状態(State)パターン | 状態と、状態遷移のできる・できないを表現する |
これらのパターンについて以下で詳しく解説する。
・口座(Account)パターン
銀行の口座、在庫数量の管理、会計などで使うパターン。以下のような仕組みで実現する。
- 関心の対象を「口座」として用意する。
- 数値の増減の「予定」を記録する。
- 数値の増減の「実績」を記録する。
- 現在の口座の「残高」を算出する。
例えば、「出荷可能な(在庫がある)商品だけを販売できる」という業務ルールがある場合、現在の残高のみで出荷可否を判断してしまうと、現在、在庫がない商品は販売することができない。
来週の入荷予定などがわかっていれば、その商品を販売できるので、「口座」クラスはこれまでの入荷・出荷実績と入荷予定・出荷予定を持つ以下のような構成のクラスとすれば過去と未来の残高を考慮した判断を行うことができる。
入荷実績などを全て DB で記録・保存する場合は例えば以下のようにクラスを実装できる。
# RailsのActiveRecordを使用した場合
# 口座モデル
class Account < ActiveRecord::Base
# Table name: accounts
#
# id :integer not null, primary key
# number :integer not null
# name :string default(""), not null
# 入荷予定、出荷予定、入荷実績、出荷実績を関連モデルとして持つ
has_many :arrival_schedules, foreign_key: :account_number, primary_key: :number
has_many :shipment_schedules, foreign_key: :account_number, primary_key: :number
has_many :arrival_histories, foreign_key: :account_number, primary_key: :number
has_many :shipment_histories, foreign_key: :account_number, primary_key: :number
# 出荷可能かどうか(残高)を判定
def can_ship?(requested_date:, requested_quantity:)
calculate_stock_on(requested_date) >= requested_quantity
end
private
def calculate_stock_on(target_date)
arrived = arrival_histories.by_date(target_date).sum(:quantity)
shipped = shipment_histories.by_date(target_date).sum(:quantity)
scheduled_arrivals = arrival_schedules.by_date(target_date).sum(:quantity)
scheduled_shipments = shipment_schedules.by_date(target_date).sum(:quantity)
arrived + scheduled_arrivals - shipped - scheduled_shipments
end
end
# 入荷予定モデル
class ArrivalSchedule < ActiveRecord::Base
# Table name: arrival_schedules
#
# id :integer not null, primary key
# account_id :integer not null
# date :date not null
# quantity :integer not null
belongs_to :account
scope :by_date, ->(date) { where('date <= ?', date) }
end
# 以下も同様の形式で作成
# 出荷予定モデル(ShipmentSchedule)
# 入荷実績モデル(ArrivalHistory)
# 出荷実績モデル(ShipmentHistory)
期日(DueDate)パターン
予定とその実行の管理を行うパターン。業務アプリケーションにおいては中核となる関心事となる。
- 約束を実行すべき期限を設定する
- その期限までに約束が適切に実行されることを監視する
- 期限切れの危険性について事前に通知する
- 期限までに実行されなかったことを検知する
- 期限切れの程度を判断する
これらのロジックは期日となる日付をラッピングした以下のような値オブジェクトとして実装できる。
class DueDate
def initialize(date)
raise ArgumentError, "date must be a Date" unless date.is_a?(Date)
@due_date = date
end
# 今日は期限切れか?
def is_expired?
is_expired_on?(Date.today)
end
# その日は期限切れか?(引数の日付が期限切れか?)
def is_expired_on?(date)
date > @due_date
end
# 期限までの残り日数
def remaining_days(date)
(@due_date - date).to_i
end
# 期限切れの警告度合いの判定
def alert_priority(as_of: Date.today)
days = remaining_days(as_of)
return :expired if days < 0
return :critical if days <= 3
return :warning if days <= 7
:none
end
end
また、このDueDate
クラスは、期日について汎用的に使い回す部品ではない。
例えば、一口に「期日」といっても、出荷期日と支払い期日という業務ルールがあった場合、それらにはそれぞれ異なるルール・約束事が存在するはずである。
それらを無理に同じクラスで表現せず、扱うデータやロジックが似ていたとしてもShipmentDueDate
クラスとPaymentDueDate
クラスというように別々のクラスとして実装するべきである。
これは異なる業務の関心事を、「期限切れを扱うクラス」という理由だけで共通化してしまうと、それぞれの期日についての業務ルールが変更になった時に副作用が発生しやすくなるため。
あくまでコードを重複させないのは、業務ロジックの整理のためなので、完全に共通となるロジックをDueDate
クラスからそれら2つのクラスで部品として再利用するようにする。
方針(Policy)パターン
業務ルールは多くの場合、複合しているため、それらをまとめて扱うためには以下のようなコレクションオブジェクトを作成する。
一つ一つのルールをRule
モジュールを include したオブジェクトとして作成し、ルールの集合に対して「全ての条件が一致する」「どれかの条件が一致する」などの判定をPolicy
クラスで行う。
class Policy
def initialize(rules: [])
unless rules.all? { |r| r.is_a?(Rule) }
raise ArgumentError, 'Ruleモジュールをincludeしたクラスのインスタンスをrulesに指定してください'
end
@rules = rules
end
def comply_with_all?(value)
@rules.each { |rule| return false if rule.ng(value) }
true # 全てのルールが遵守されていればtrue
end
def comply_with_some?(value)
@rules.each { |rule| return true if rule.ok(value) }
false # どのルールにも適合しない場合はfalse
end
end
# 以下のモジュールをrules配列に含めるクラスにincludeする
module Rule
def ok(data)
raise NotImplementedError, "#{self.class} は ok(data) を実装してください"
end
def ng(data)
!ok(data)
end
end
# includeするクラスの例
class AdultOnly
include Rule
def ok(age)
age >= 20
end
end
状態(State)パターン
別記事で詳しく解説しています。
参考文献
この記事は以下の情報を参考にして執筆しました。