かとじゅんの技術日誌

技術の話をするところ

DDDのリポジトリのインターフェイスをどのように設計すべきか

scala-dddbase

scala-dddbaseではどのようなインターフェイスとしているか?

以下のようになっています。

戻り値はモナドにラップして返すことを想定しているので、高階型としてM型を指定できるようにしました。一般的にMには、同期型リポジトリとしてはTry型、非同期型リポジトリとしてはFuture型を指定します。

副作用がない読み込み系メソッドはM[E]もしくはM[Seq[E]]で返します。一方、副作用がある書き込み系メソッドはM[Result],M[Results]を返します(Result, Resultsはリポジトリの新しい状態とエンティティを含む型としています。オンメモリなリポジトリの場合は新しいインスタンスを返す不変型リポジトリを実装し、DBなどのストレージに対応づく場合は可変リポジトリとしてthisを返すにようします)

リポジトリはあらゆる永続化技術から中立でなければならないが、現実問題としてDBMS実装の場合はコネクションやトランザクション管理のためのオブジェクトを渡す必要があるため、抽象的なデータ型としてCtx型を渡せるようにしています。利用する際は内部でパターンマッチをして必要な情報を取り出すなどをやっています。この辺はもっといい方法(多分Readerモナドでいける気がする)がありそうだが妥協しています。

EntityReader

trait EntityReader[ID <: Identifier[_], E <: Entity[ID], M[+  _]] extends EntityIO[M] {
  
  /**
   * 識別子からエンティティを解決する。
   * 
   * @param identifier 識別子
   * @return 正常: エンティティ
   *         例外: EntityNotFoundExceptionは、エンティティが存在しない場合。
   */
  def resolveBy(identifier: ID)(implicit ctx: Ctx): M[E]

  /**
   * 複数の識別子から複数のエンティティを解決する。
   * 
   * @param identifier 識別子
   * @return 正常: エンティティの集合
   *         例外: RepositoryExceptionは、リポジトリにアクセスできなかった場合。
   */
  def resolveByMulti(identifiers: ID*)(implicit ctx: Ctx): M[Seq[E]]

}

EntityWriter

trait EntityWriter[ID <: Identifier[_], E <: Entity[ID], M[+ _]] extends EntityIO[M] {

  type This <: EntityWriter[ID, E, M]
  type Result <: ResultWithEntity[This, ID, E, M]
  type Results <: ResultWithEntities[This, ID, E, M]

  /**
   * エンティティを保存する。
   *
   * @param entity 保存する対象のエンティティ
   * @return 正常: リポジトリインスタンスと保存されたエンティティ
   *         例外: RepositoryExceptionは、リポジトリにアクセスできなかった場合。
   */
  def store(entity: E)(implicit ctx: Ctx): M[Result]

  /**
   * 複数のエンティティを保存する。
   *
   * @param entity 保存する対象のエンティティ
   * @return 正常: リポジトリインスタンスと保存されたエンティティ
   *         例外: RepositoryExceptionは、リポジトリにアクセスできなかった場合。
   */
  def storeMulti(entities: E*)(implicit ctx: Ctx): M[Results]

}

EntityIO

/**
 * エンティティをIOするためのトレイト。
 */
trait EntityIO[M[+ _]] {

  type Ctx = EntityIOContext[M]

}

ResultWithEntity

/**
 * [[org.sisioh.dddbase.core.lifecycle.EntityWriter]]の新しい状態とエンティティを保持する値オブジェクト。
 *
 * @tparam EW [[org.sisioh.dddbase.core.lifecycle.EntityWriter]]の型
 * @tparam ID エンティティの識別子の型
 * @tparam E エンティティの型
 * @tparam M モナドの型
 */
trait ResultWithEntity[+EW <: EntityWriter[ID, E, M], ID <: Identifier[_], E <: Entity[ID], M[+A]] {

  /**
   * 結果
   */
  val result: EW

  /**
   * エンティティ
   */
  val entity: E

}

ResultWithEntities

/**
 * [[org.sisioh.dddbase.core.lifecycle.EntityWriter]]の新しい状態と複数のエンティティを保持する値オブジェクト。
 *
 * @tparam EW [[org.sisioh.dddbase.core.lifecycle.EntityWriter]]の型
 * @tparam ID エンティティの識別子の型
 * @tparam E エンティティの型
 * @tparam M モナドの型
 */
trait ResultWithEntities[+EW <: EntityWriter[ID, E, M], ID <: Identifier[_], E <: Entity[ID], M[+A]] {

  /**
   * 結果
   */
  val result: EW

  /**
   * エンティティ
   */
  val entities: Seq[E]

}

考えられるI/Oインターフェイス

上記の設計に至るまでに考えたインターフェイスについてまとめます。

読み込み系のメソッド

戻り値として値だけ返す方法

エンティティが存在する場合は戻り値で返し、存在しないという状況と、その他の例外状況を、実行時例外で返すことになります。

trait Repository[ID, E] {
  def resolveBy(id: ID)(implicit ctx: Ctx): E
}

val response = try {
  val entity = repository.resolveBy(id)
  Ok(entity.toJson)
} catch {
  case EntityNotFoundException(id) => BadRequest(id)
  case ex: RepositoryException => InternalServerError(ex)
}

Option型でラップする方法

エンティティが存在する場合はSomeでラップして返し、存在しない場合はNoneが返されます。それ以外の例外状況は実行時例外になります。

trait Repository[ID, E] {
  def resolveBy(id: ID)(implicit ctx: Ctx): Option[E]
}

val response = try {
  repository.resolveBy(id).fold(NotFound(id)){ entity =>
    Ok(entity.toJson)
  }
} catch {
  case ex: Exception => InternalServerError(ex)
}

Either型でラップする方法

エンティティが存在する場合はエンティティをRightでラップして返し、存在しない場合はEntityNotFoundErrorで返し、それ以外の例外状況は対応するErrorで返す。

sealed trait Error
case class EntityNotFoundError(id: Identifier) extends Error
case class RepositoryError(id: Identifier, throwable: Throwable) extends Error

trait Repository[ID, E] {
  def resoleBy(id: ID)(implicit ctx: Ctx): Either[Error, E]
}

val response = repository.resolveBy(id).fold({
  case EntityNotFoundError(id) => NotFound(id)
  case RepositoryError(id, thr) => InternalServerError(id)
}, {
  entity =>
    Ok(entity.toJson)
})

Either[Error, Option[E]]版の場合は、エンティティが存在しない場合はRigiht(None)として返す。

trait Repository[ID, E] {
  def resoleBy(id: ID)(implicit ctx: Ctx): Either[Error, Option[E]]
}

val response = repository.resolveBy(id).fold({
  case RepositoryError(id, thr) => InternalServerError(id)
}, {
  entityOpt =>
    entityOpt.fold(NotFound(id)){ entity =>
      Ok(entity.toJson)
    }
})

Try型でラップする方法

Either型とほぼ変わりませんが、例外状況としてFailureにはどんなExceptionも格納できます。

trait Repository[ID, E] {
  def resolveBy(id: ID)(implicit ctx: Ctx): Try[E]
}

val response = repository.resolveBy(id).map{ entity =>
  Ok(entity.toJson)
}.recover{
  case EntityNotFoundException(id) => NotFound(id)
  case ex: RepositoryException => InternalServerError
}.getOrElse(InternalServerError)

エンティティが存在しない状況をNoneで表す場合は以下。

trait Repository[ID, E] {
  def resolveBy(id: ID)(implicit ctx: Ctx): Try[Option[E]]
}

val response = repository.resolveBy(id).map{ entityOpt =>
  entityOpt.fold(NotFound(id)(_.toJson)
}.recover{
  case ex: RepositoryException => InternalServerError
}.getOrElse(InternalServerError)

Future型でラップする方法

同期か非同期かという違いですが、Try型のインターフェイエスとほとんど変わりません。

trait Repository[ID, E] {
  def resolveBy(id: ID)(implicit ctx: Ctx): Future[E]
}

val response = repository.resolveBy(id).map{ entity =>
  Ok(entity.toJson)
}.recover{
  case EntityNotFoundException(id) => NotFound(id)
  case ex: RepositoryException => InternalServerError
}.getOrElse(InternalServerError)
trait Repository[ID, E, M[+_]] {
  def resolveBy(id: ID)(implicit ctx: Ctx): Future[Option[E]]
}

val response = repository.resolveBy(id).map{ entityOpt =>
  entityOpt.fold(NotFound(id)(_.toJson)
}.recover{
  case ex: RepositoryException => InternalServerError
}.getOrElse(InternalServerError)

書き込み系メソッド

戻り値をUnitする方法

エンティティを保存できなかった場合は例外がスローされます。引数に渡したエンティティと書き込んだ結果のエンティティが異なる場合はresolveByで再度取得する必要があるかもしれません。副作用を起こすメソッドであり、テスタビリティが下がるのが難点。

trait Repository[ID, E] {
  def store(entity: E)(implicit ctx: Ctx): Unit
}

val response = try {
  repository.store(entity)
  Ok(repository.resolveBy(entity.id))
} catch {
  case ex: RepositoryException => InternalServerError
}

保存されたエンティティを返す方法

値を返すが以前として、副作用を起こすメソッドであり、テスタビリティが下がる。

trait Repository[ID, E] {
  def store(entity: E)(implicit ctx: Ctx): E
}

val response = try {
  Ok(repository.store(entity).toJson)
} catch {
  case ex: RepositoryException => InternalServerError
}

新しいリポジトリインスタンスとエンティティを返す方法

Stateモナドの応用例として、新しいリポジトリインスタンスとエンティティを返すことによって、純粋関数化しテスタビリティを向上させる方法。これによって不変リポジトリが実装可能になる(DBMSなど不変との相性が悪い実装の場合は、可変リポジトリとして、(this, storedEntity) としてUnitを返す変わりにthisを返すとよい)。

trait Repository[ID, E] {
  def store(entity: E)(implicit ctx: Ctx): (Repository[ID, E], E)
}

val response = try {
  val (newRepo, storedEntity) = repository.store(entity))
  // 必要に応じて newRepository.store, resolveBy など
  Ok(storedEntity.toJson)
} catch {
  case ex: RepositoryException => InternalServerError
}

Etiher型にラップする方法

trait Repository[ID, E] {
  def store(entity: E)(implicit ctx: Ctx): Either[Error, (Repository[ID, E], E)]
}

repository.store(entity).fold({
  case ex: RepositoryException => InternalServerError
},{ (newRepo, storedEntity) =>
  // 必要に応じて newRepository.store, resolveBy など
  Ok(storedEntity.toJson)
})

Try型にラップする方法

trait Repository[ID, E] {
  def store(entity: E)(implicit ctx: Ctx): Try[(Repository[ID, E], E)]
}

repository.store(entity).map{ (newRepo, storedEntity) =>
  // 必要に応じて newRepository.store, resolveBy など
  Ok(storedEntity.toJson)
}.recovery{
  case ex: RepositoryException => InternalServerError
}

Future型でラップする方法

trait Repository[ID, E] {
  def store(entity: E)(implicit ctx: Ctx): Future[(Repository[ID, E], E)]
}

repository.store(entity).map{ (newRepo, storedEntity) =>
  // 必要に応じて newRepository.store, resolveBy など
  Ok(storedEntity.toJson)
}.recovery{
  case ex: RepositoryException => InternalServerError
}

などなど、挙げればきりがないのですが…。

Scalaz

Scalazでは以下のようになりますかね。

trait Repository[ID, E] {
  def store(entity: E)(implicit ctx: Ctx): \/[Error, State[Repository[ID, E], E]]
}

そしてFreeモナド

あとは、Freeモナドを使う方法がありそうですね。こっちのがモナドが階層化しないようにできるのでよいかもしれない。当然、インタプリタを書く手間はありますが。

https://gist.github.com/xuwei-k/469a2213c7773274272f

あ、Operationalモナドの方がよいかな?

まとめ

とはいえ、現状 以下を採用しています。

trait Repository[ID, E] {
  def resolveBy(id: ID)(implicit ctx: Ctx): Try[E]
  def resolveBy(id: ID)(implicit ctx: Ctx): Future[E]
  def store(entity: E)(implicit ctx: Ctx): Try[(Repository[ID, E] , E)]
  def store(entity: E)(implicit ctx: Ctx): Future[(Repository[ID, E] , E)]
}

val response = (for {
  (newUserRepo, storedUser) <- userRepository.store(user)
  (newGroupRepo, storedGroup) <- groupRepository.store(group)
} yield {
  Ok(toJson(storedUser, storedGroup))
}).recover{
  case ex: RepositoryException => InternalServerError
}

やはりモナドが入れ子構造になるとfor式やmapなどが書きにくくなるので、できるだけ階層化させないというのと、Try/Futureはシンタックスが似ているので、同期や非同期のコードの切り替えがしやすいからです。まぁ、例外のハンドリングだけ考えるとEitherでもよいかも。

def resolveBy(id: ID)(implicit ctx: Ctx): Try[E] = Try {
  sql"select * from users where id = {id}"
    .bindByName('id -> id)
    .map(convertToEntity)
    .single
    .apply()
    .getOrElse(throw new EntityNotFoundException(id))
}

def resolveBy(id: ID)(implicit ctx: Ctx): Future[E] = {
  val executor = getExecutor(ctx)
  future {
    internalRepository.resolveBy(id).get
  }
}