Scalaのcase classに副作用のある振る舞いを持たせる時のパターン

不変オブジェクトを使ったプログラムを書く時に便利なcase classですが、case classのメリットを生かしつつ副作用を扱うためにはどのような書き方をするのが良いでしょうか。具体的には外部APIやデータベースへのアクセスをcase classを使ってどう実装するのが良いか考えます。

例として次のような簡単なUserクラスを用意します。

case class User(id: Int, name: String)

Userの名前を変更するメソッドを追加してみます。これはcopyメソッドを使えば簡単です。ここまではサンプルコードでよく目にするような例です。

case class User(id: Int, name: String) {
  def rename(name: String): User = copy(name = name)
}

さて、このユーザー情報をデータベースに保存することにしましょう。保存処理自体はUserRepositoryというクラスを利用します。次の例はあまり良くない例です。

case class User(id: Int, name: String) {
  def rename(name: String): User = copy(name = name)
  def save(): Unit = UserRepository.save(this)
}

object UserRepository {
  def save(user: User): Unit = ...
}

UserRepositoryはシングルトンとして実装しました。簡単ですがUser#saveは副作用を伴うメソッドで、テストをするのにデータベースの管理までが必要になって面倒です。

こういった副作用を分離するために関数型っぽいアプローチを取ることもできますが、ここではJavaっぽくインタフェースで副作用のある処理との結合を切り離し、副作用を制御できるようにします。

trait UserRepository {
  def save(user: User): Unit
}

class UserRepositoryImpl extends UserRepository {
  def save(user: User): Unit = ...
}

class UserRepositoryMock extends UserRepository {
  var users: Seq[User] = Seq.empty
  def save(user: User): Unit = users :+ user
}

UserRepositoryをインタフェースとして実装し、Userクラスにはこのインタフェースに対しての実装を書くことにしました。これでテストの時にMockを使えばDBの管理まで考える必要がなくなります。

さて、このUserRepositoryをcase classに組み込むとどうなるでしょうか。

case class User(
    id: Int,
    name: String,
    userRepository: UserRepository) {
  def rename(name: String): User = copy(name = name)
  def save(): Unit = userRepository.save(this)
}

コンストラクタでUserRepositoryを渡してみました。しかしこれはNGです。case classには自動でequalsメソッドが付いてきますが、そのメソッドは比較対象である2つのcase classのフィールドが同じかを見ています。ということはUserRepositoryが何を持って同値とするかを決めておかないとまずいのです。つまりUserRepositoryのequalsを実装する、という作業が必要ですが正直面倒。そもそもUserRepositoryに同値性を持たせることは必要だろうか?

コンストラクタでは無くメソッドの引数で渡すというのはどうでしょうか。

case class User(
    id: Int,
    name: String) {
  def rename(name: String): User = copy(name = name)
  def save(userRepository: UserRepository): Unit = userRepository.save(this)
}

これはcase classの使い方として間違ってはいないですが、どうせそのUserインスタンスではいつも同じUserRepositoryを使うはずなのに、メソッド引数で毎回指定するというのは正直面倒です。そこでこの引数をimplicitにして引数を省略…とかやると破滅するのでやめましょう。

で、私の結論ですが、case classに副作用を混ぜようとすることを諦めて、補助的なクラスに切り出せば良いと思います。Userの操作なのでUserOpsとかでいいかな。

case class User(
    id: Int,
    name: String) {
  def rename(name: String): User = copy(name = name)
}

class UserOps(userRepository: userRepository) {
  def save(user: User): Unit = userRepository.save(user)
}

これならcase classのequalsを壊すこともないし、テストもしやすい。主観だけどコンパニオンオブジェクトみたいなものと捉えれば違和感もあまりない。

ついでに、DIコンテナの恩恵を受けるのが簡単というメリットもあります。

case class User(
    id: Int,
    name: String) {
  def rename(name: String): User = copy(name = name)
}

class UserOps @Inject() (userRepository: userRepository) {
  def save(user: User): Unit = userRepository.save(user)
}

ドメイン駆動設計ではよくデータと振る舞いが一緒にあるべきと言われますが、1つのクラスに押し込める必要はないかと思います。この場合はUser+UserOpsで1つのUserモデルと考えることになります。汎用的で使いやすいパターンではないかと思います。

というわけでタイトルは嘘で「Scalaのcase classに副作用のある振る舞いを持たせないパターン」でした。