見出し画像

Rubyで完全コンストラクタを意識したEntityクラスを実装してみた

はじめに

開発の竹内(@kenta_714)です。
PharmaXのアドベントカレンダーの11日目を担当します。

PharmaXではRuby on RailsでのDDDを採用しています。
PharmaXの特徴として、EntityクラスはActiveRecordとは分離して完全コンストラクタを意識して実装しています。
いわゆるsetterメソッドというものは用意せず、値を変更したい場合はその都度インスタンスを新しく作成し直しています。

今回は弊社のEntityクラスの実装と工夫した点を紹介いたします。

完全コンストラクタとは

setterメソッドなどの自身の値を更新するメソッドを実装せず、コンストラクタで全てのプロパティの値を確定させ、作成後のインスタンスは不変とするパターンです。

メリット

可読性、保守性が高まります。
完全コンストラクタはインスタンスの値が変化することがありません。
そのため、setterや副作用のあるようなメソッドを通して値が変化したかどうかを気にする必要がなくなります。
意図しないタイミングで値が変化することもなく、シンプルな実装になりやすいため、可読性、保守性が高いコードとなります。

デメリット

毎回インスタンスを生成するためオーバーヘッドがかかります。
また一部の値を更新するためだけでもコンストラクタを実行しなければならないため、コードが冗長になりやすいでしょう。
そのためビルダーやファクトリーなどの生成のデザインパターンと合わせて使用されることも多くあります。

PharmaXでのEntityクラス

最初にも書いた通り完全コンストラクタを意識して実装しています。

正確に言うと意識しているだけで完全コンストラクタではありません。
Rubyのattr_readerをベースとしているため内部メソッドからは書き換え可能だからです。

だからと言って実際に実務上で問題になったことはなく、内部からの値変更は禁止する、というルールと徹底することで完全コンストラクタっぽく運用できています。

Rubyでの実装

ベースクラスとして以下のような抽象クラスを実装しました。
完全コンストラクタとして実装したいクラスはこのクラスを継承します。

サンプルコード

class BaseEntity
  def initialize
    raise NotImplementedError
  end

  def self.attr_reader(*vars)
    @@attributes ||= []
    @@attributes.concat vars
    super
  end

  def attributes
    @@attributes
  end

  def rebuild(renew_attr = {})
    new_attr = {}
    attributes.each do |key, _|
      new_attr[key.to_sym] = instance_variable_get("@#{key}") if instance_variable_defined?("@#{key}")
    end

    self.class.new(**new_attr.merge(renew_attr))
  end
end

rebuild

このメソッドが今回の実装の中で一番のポイントになります。
引数で渡されたattributesのみを更新した新しいインスタンスを作成して返すメソッドです。

まずgetterメソッドであるattributesを用いてクラス変数の@@atributesを取得し、取得した@@atributesのkeyをloopします。
loop内では、継承先のクラスでkeyが定義されているかをinstance_variable_definedによってチェックし、定義されていた場合はその値をinstance_variable_getで取得して新しいハッシュに渡します。

次に新しく作成したハッシュと、引数で受け取った部分更新対象のattributeのハッシュについてmergeメソッドを用いて統合します。
最後に統合されたハッシュを展開したものを引数としてコンストラクタに渡すことで、引数で渡された値のみが更新された新しいインスタンスを作成しています。

BaseEntityを継承したサンプル(Hoge)と使用例を紹介します。

継承クラスの例

class Hoge < BaseEntity
  attr_reader :name, :age

  def initialize(name:, age:)
    # ここにEntityのチェックルールを記載する
    # 例) raise DomainError if age < 20

    @name = name
    @age = age
  end
end

使用例

hoge = Hoge.new(name: '山田', age: 20)
=> #<Hoge:0x00000001087afe28 @age=20, @name="山田">

# nameだけ更新された新しいインスタンスが作成される
hoge.rebuild(name: '佐藤')
=> #<Hoge:0x000000010926e2b0 @age=20, @name="佐藤">

# ageだけ更新された新しいインスタンスが作成される
hoge.rebuild(age: 25)
=> #<Hoge:0x000000010ba4e9d8 @age=25, @name="山田">

まとめ

今回はPharmaXでのRubyによるEntityの実装例を紹介しました。
完全コンストラクタを活用すると、値の更新や副作用を気にする必要がないため、可読性や保守性の高いコードを実装することができます。
もちろん今回紹介したコードはEntityだけでなく、DTOやRequestクラス、Responseクラスなどさまざまなところで活用できると思います。
ぜひ参考にしてください。

余談

attributesを利用すれば、他にもオブジェクトの特性に合わせた汎用的な処理の実装ができます。

例えば、attributesをキャメルケースにする場合は以下の実装となります。
deep_transform_keysでハッシュの全ての値に対してcamelize(:lower)を実行しています。

このようにdeep_transform_keysを用いることで全てのハッシュに対して処理を実行できるため、自由にカスタマイズした実装を行えます。

  def to_lower_camel_case
    hash = {}
    attributes.each do |key, _|
      hash[key] = instance_variable_get("@#{key}") if instance_variable_defined?("@#{key}")
    end

    hash.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym }
  end

最後に

PharmaXではアドベントカレンダーをやっています。
他にも技術記事を載せていますのでぜひご覧ください!
https://qiita.com/advent-calendar/2022/pharma-x


PharmaXでは定期的にテックイベントをオンラインで開催しています!
2023年1月は、金融業界・花き業界・薬局業界という異なる業界でDX推進をリードするスタートアップ企業3社が集まり、それぞれのオペレーションを担っているドメインエキスパートとプロダクト開発チームがどのように連携しながらプロダクトや開発組織を作っているのかについてディスカッションします。
ぜひお気軽にご参加ください。


この記事が気に入ったらサポートをしてみませんか?