😇

値オブジェクト

2024/07/12に公開

値オブジェクトを説明する前に、、、

不変

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については
https://qiita.com/k-penguin-sato/items/54189d5ed4e5f7463266

実際に、エンティティが値オブジェクトを持つ例

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)がエンティティの場合に値オブジェクトを使用する場合はどうしよ。

こいつを使えば解決らしい。
https://railsdoc.com/page/composed_of

ざっくり説明すると、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クラスのが良さそう。
https://docs.ruby-lang.org/ja/latest/method/Data/s/define.html

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

参考文献

https://qiita.com/takuya0301/items/07f1393a8d0f62664ba2
https://amzn.asia/d/06fywJv8

Discussion