値オブジェクト
値オブジェクトを説明する前に、、、
不変
class Money {
constructor(public amount: number, public currency: string) {
if (amount < 0) {
throw new Error("金額は0以上で指定してください");
}
if (!currency) {
throw new Error("通貨単位を指定してください");
}
}
add(other: number): void {
this.amount += other;
}
}
add
メソッドではインスタンス変数(amount
)の値が変更されている。
インスタンス変数の値が変更されると、
- いつ変更したのかわからん
- 仕様変更したとき意図しない値に置き換わる
などの副作用が起きてしまう。
=> インスタンス変数はイミュータブル(不変)であるべき
でもでも、インスタンス変数を不変にすると、値を変更できなくない?(あたりまえだが)
=> 変更値を持ったインスタンスを返せば良い
class Money {
constructor(private readonly amount: number, private readonly currency: string) {
if (amount < 0) {
throw new Error("金額は0以上で指定してください");
}
if (!currency) {
throw new Error("通貨単位を指定してください");
}
}
add(other: number): Money {
const added = this.amount + other;
return new Money(added, this.currency);
}
}
(Rubyであれば、attr_accessor
からattr_reader
にしたり)
「これで完璧」、、、かと思えるが
なぜ人は値オブジェクトを使用するのか
実際に使ってみたところ、
const money: Money = new Money(3000, 'yen');
const ticketCount: number = 3;
money.add(ticketCount);
//=> Money(amount: 3003, currency: 'yen')
金額ではなく、チケットの枚数を加算してしまった。。。。
確実に金額が渡るようにしたい
=> このMoneyクラスをメソッドの引数にくるような設計にする
class Money {
// 略
// Moneyオブジェクトがくるように型指定
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("通貨単位が違います");
}
const added = this.amount + other.amount;
return new Money(added, this.currency);
}
}
エンティティがないMoneyクラスこそ値オブジェクトである
(エンティティとは概念的な同一性)
他のクラスでもお金を扱うメソッドがあれば、「引数は金額が確実に来るようにしたい!」なんてこともあるはず。
=> 別のクラス(モデル)のメソッドの引数にこのMoneyクラスを使用すれば解決
『では我らがRuby on Railsで値オブジェクトを作成し、カッコよく使用したい!!!!!』
できる人みたいに値オブジェクトをRailsで使いたい
Railsで値オブジェクトを表現する方法の一つにStructクラスの継承を行う方法があるみたい。
(他には、普通にattr_reader :amount, :currency
の素のクラスを作って、それを値オブジェクトとしたり、ApplicationRecordを継承してゴニョゴニョ)
class Money < Struct.new(:amount, :currency)
end
Structについては
実際に、エンティティが値オブジェクトを持つ例
class User
attr_accessor :name, :age, :money
def initialize(name, age, money)
@name = name
@age = age
@money = money
end
end
money = Money.new(1000, 'yen')
user = User.new('shunya', 18, money)
puts user.money
#=> <struct Money amount=1000, currency="yen">
じゃあ、ActiveRecord(ApplicationRecord)がエンティティの場合に値オブジェクトを使用する場合はどうしよ。
こいつを使えば解決らしい。
ざっくり説明すると、Moneyクラスをモデルに埋め込んでしまうことができるらしい。
使ってみる。
とりあえず、値オブジェクトを作成
class Money < Struct.new(:amount, :currency)
end
(↑置き場所ってどこがよいのかな)
モデルの作成
rails g model User name:string age:integer money_amount:integer money_currency:string
class User < ApplicationRecord
composed_of :money, mapping: [['money_amount', 'amount'], ['money_currency', 'currency']]
end
money = Money.new(1000, 'yen')
user = User.new(name: 'shunya', age: 18, money: money)
#=> <User:0x0000000109b68a50 id: nil, name: "shunya", age: 18, money_amount: 1000, money_currency: "yen", created_at: nil, updated_at: nil>
user.save
puts user.money
#=> <struct Money amount=1000, currencfy="yen">
「いろんなパターン」
shojikinという名前で扱いたい場合
class User < ApplicationRecord
composed_of :shojikin, class_name: 'Money', mapping: [['money_amount', 'amount'], ['money_currency', 'currency']]
end
money = Money.new(1000, 'yen')
user = User.new(name: 'shunya', age: 18, shojikin: money)
#=> <User:0x0000000109b68a50 id: nil, name: "shunya", age: 18, money_amount: 1000, money_currency: "yen", created_at: nil, updated_at: nil>
Userモデルのカラムがmoney_amountしかない場合(money_currencyが存在しない)
class User < ApplicationRecord
composed_of :money, mapping: [['money_amount', 'amount']], constructor: Proc.new { |amount| Money.new(amount, 'yen') }
end
money = Money.new(1000, 'yen')
user = User.new(name: 'shunya', age: 18, money: money)
#=> <User:0x000000010fc931e0 id: nil, name: "shunya", age: 18, money_amount: 1000, created_at: nil, updated_at: nil>
user.money
=> #<struct Money amount=1000, currency="yen">
注意点
値オブジェクトを使用せず、直接Userのmoney_amountを変更することができてしまう。
money = Money.new(1000, 'yen')
user = User.new(name: 'shunya', age: 18, money: money)
# 直接money_amountの値を変更する
user.money_amount = 3000
user.money
#=> <struct Money amount=1000, currency="yen">
user.money.amount
#=> 1000
user.money_amount
#=> 3000
追加情報
今回、値オブジェクトをStructクラスの継承で行ったが、Dataクラスのが良さそう。
Structクラスだと値の変更ができてしまう。
money = Money.new(1000, 'doller')
=> #<struct Money amount=1000, currency="doller">
money.amount = 3000
=> 3000
money
=> #<struct Money amount=3000, currency="doller">
MoneyをDataクラスにすると、、
Money = Data.define(:amount, :currency)
data_money = Money.new(amount: 1000, currency: 'yen')
#=> <data Money amount=1000, currency="yen">
data_money.amount = 3000
#=> <anonymous>': eval:4:in `<main>': undefined method `amount=' for #<data Money amount=1000, currency="yen"> (NoMethodError)
ちなみにこんな感じに、型のチェックもできるよ(完全コンストラクタ)
Money = Data.define(:amount, :currency) do
def initialize(amount:, currency:)
if !amount.is_a?(Integer) || !currency.is_a?(String)
raise StandardError, "型がまちがってるよ。頑張ろうね。"
end
super
end
end
追記
もう少し踏み込んで設計してみた(正しいかどうかはわからん)
コストや請求書の金額を、税抜、税込み、税額とで分けて保存したいとき
- 税額の値オブジェクト
TaxAmount = Struct.new(:amount) do
def initialize(amount)
raise ArgumentError, "Amount must be an integer" unless amount.is_a?(Integer)
super(amount)
end
end
- 税込金額の値オブジェクト
IncludingTaxAmount = Struct.new(:amount) do
def initialize(amount)
raise ArgumentError, "Amount must be an integer" unless amount.is_a?(Integer)
super(amount)
end
end
- 税抜金額の値オブジェクト
ExcludingTaxAmount = Struct.new(:amount) do
def initialize(amount)
raise ArgumentError, "Amount must be an integer" unless amount.is_a?(Integer)
super(amount)
end
end
- オブジェクトとファクトリクラスの両方の役割を担っているMoneyクラス
(ファクトリメソッドとかは、サービスクラスに分けてもよいかも)
class Money
attr_reader :including_tax_amount, :tax_amount, :excluding_tax_amount
def initialize(including_tax_amount:, tax_amount:, excluding_tax_amount:)
raise ArgumentError, "Including Tax Amount must be an instance of IncludingTaxAmount, but got #{including_tax_amount.class}" unless including_tax_amount.is_a?(IncludingTaxAmount)
raise ArgumentError, "Excluding Tax Amount must be an instance of ExcludingTaxAmount, but got #{excluding_tax_amount.class}" unless excluding_tax_amount.is_a?(ExcludingTaxAmount)
raise ArgumentError, "Tax Amount must be an instance of TaxAmount, but got #{tax_amount.class}" unless tax_amount.is_a?(TaxAmount)
@including_tax_amount = including_tax_amount.amount
@tax_amount = tax_amount.amount
@excluding_tax_amount = excluding_tax_amount.amount
freeze
end
# 税込金額+税率から作成
def self.from_including_tax_and_rate(including_tax_amount:, tax_rate:)
tax_amount = calculate_tax_amount(including_tax_amount, tax_rate)
excluding_tax_amount = calculate_excluding_tax_amount(including_tax_amount, tax_amount)
new(including_tax_amount:, tax_amount:, excluding_tax_amount:)
end
# 税込金額+税額から作成
def self.from_including_tax_and_tax_amount(including_tax_amount:, tax_amount:)
excluding_tax_amount = calculate_excluding_tax_amount(including_tax_amount, tax_amount)
new(including_tax_amount:, tax_amount:, excluding_tax_amount:)
end
# 税込金額+税額+税抜金額から作成
def self.from_all_amounts(including_tax_amount:, excluding_tax_amount:, tax_amount:)
new(including_tax_amount:, tax_amount:, excluding_tax_amount:)
end
# TODO: 税抜金額+税率から作成
# TODO: 税抜金額+税額から作成
private_class_method :new
def self.calculate_tax_amount(including_tax_amount, tax_rate)
raise ArgumentError, "Including Tax Amount must be an instance of IncludingTaxAmount, but got #{including_tax_amount.class}" unless including_tax_amount.is_a?(IncludingTaxAmount)
raise ArgumentError, "Tax Rate must be a number" unless tax_rate.is_a?(Numeric)
return TaxAmount.new(0) if including_tax_amount.amount.negative?
TaxAmount.new((including_tax_amount.amount / (1 + tax_rate) * tax_rate).round)
end
def self.calculate_excluding_tax_amount(including_tax_amount, tax_amount)
raise ArgumentError, "Including Tax Amount must be an instance of IncludingTaxAmount, but got #{including_tax_amount.class}" unless including_tax_amount.is_a?(IncludingTaxAmount)
raise ArgumentError, "Tax Amount must be an instance of TaxAmount, but got #{tax_amount.class}" unless tax_amount.is_a?(TaxAmount)
ExcludingTaxAmount.new(including_tax_amount.amount - tax_amount.amount)
end
end
請求書やコスト(Invoice, Cost)に値オブジェクトを設定設定
class Invoice < ApplicationRecord
composed_of :amounts, class_name: "Money", allow_nil: true, mapping: [["including_tax_amount", "including_tax_amount"], ["tax_amount", "tax_amount"], ["excluding_tax_amount", "excluding_tax_amount"]]
...略
end
class Cost < ApplicationRecord
composed_of :amounts, class_name: "Money", allow_nil: true, mapping: [["including_tax_amount", "including_tax_amount"], ["tax_amount", "tax_amount"], ["excluding_tax_amount", "excluding_tax_amount"]]
...略
end
実際に値を設定する場合
including_tax_amount = IncludingTaxAmount.new(1100)
money = Money.from_including_tax_and_rate(including_tax_amount:, tax_rate: 0.1)
cost = Cost.new(amounts: money)
cost.amounts.including_tax_amount # => 1100
cost.amounts.tax_amount # => 100
cost.amounts.excluding_tax_amount # => 1000
参考文献
Discussion