かとじゅんの技術日誌

技術の話をするところ

DDDリポジトリを楽に実装するライブラリ

DDDのリポジトリを実装するのがダルいので、ライブラリ化したというか前から書いていたけど、Redis, Memcachedなどの実装も追加したので、簡単に説明を書いてみる。 プロジェクトなどで自前で実装する際も、参照実装として参考になると思います。Scalaの例だけど、他の言語でも参考にはなるんじゃないかと勝手に想像してます。

https://github.com/j5ik2o/scala-ddd-base

デフォルトで対応している永続化技術は、以下。

  • JDBC
    • SkinnyORM
    • Slick3
  • NOSQL
    • Memcached
    • Redis
    • Guava Cache
  • Freeモナド
    • こちらは永続化そのものは行いません。上記どれかの実装に委譲することになります。

何が楽になるかというと、上記向けのリポジトリの実装があるので、Daoだけ用意すればリポジトリが実装できるようになります。

中核になるトレイト

中核となる抽象トレイトはこれ。実装はついてないです。

https://github.com/j5ik2o/scala-ddd-base/tree/master/core/src/main/scala/com/github/j5ik2o/dddbase

IOするのはAggregateです。リポジトリによっては実装するメソッドが異なるのでトレイトは細かく分かれています。Mは型コンストラクタです。

trait AggregateSingleReader[M[_]] extends AggregateIO[M] {
  def resolveById(id: IdType): M[AggregateType]
}

SkinnyORM向け実装トレイト

一例としてSkinnyORM向けに実装を提供するトレイトの説明を簡単にします。Mはここでは、ReaderT[Task, DBSession, A]としています。Taskmonix.eval.Taskです。この型は実装ごとに変わります。たとえば、Redis向けの実装では、ReaderT[Task, RedisConnection, A]になります。

実装を提供するトレイトはAggregateIOBaseFeatureを継承しますが、ほとんどのロジックでSkinnyDaoSupport#Daoを実装したオブジェクトに委譲します。つまり、リポジトリを作る場合は、これらのトレイトをリポジトリのクラスにミックスインして、Daoの実装を提供するだけでよいことになります。

object AggregateIOBaseFeature {
  type RIO[A] = ReaderT[Task, DBSession, A]
}

trait AggregateIOBaseFeature extends AggregateIO[RIO] {
  override type IdType <: AggregateLongId
  type RecordType <: SkinnyDaoSupport#Record
  type DaoType <: SkinnyDaoSupport#Dao[RecordType]

  protected val dao: DaoType
}

trait AggregateSingleReadFeature extends AggregateSingleReader[RIO] with AggregateBaseReadFeature {

  override def resolveById(id: IdType): RIO[AggregateType] =
    for {
      record <- ReaderT[Task, DBSession, RecordType] { implicit dbSession: DBSession =>
        Task {
          dao.findBy(byCondition(id)).getOrElse(throw AggregateNotFoundException(id))
        }
      }
      aggregate <- convertToAggregate(record)
    } yield aggregate

}

実装サンプル

実装する際は、Aggregate*Featureのトレイトをミックスしてください(UserAccountRepositoryは実装を持たない抽象型です)。あとはDaoの実装の提供だけです。以下の例では、UserAccountComponentUserAccountRecord, UserAccountDaoを提供します。

object UserAccountRepository {
  type BySlick[A] = Task[A]
  def bySkinny: UserAccountRepository[BySkinny] = new UserAccountRepositoryBySkinny
}

class UserAccountRepositoryBySkinny
    extends UserAccountRepository[BySkinny]
    with AggregateSingleReadFeature
    with AggregateSingleWriteFeature
    with AggregateMultiReadFeature
    with AggregateMultiWriteFeature
    with AggregateSingleSoftDeleteFeature
    with AggregateMultiSoftDeleteFeature
    with UserAccountComponent {

  override type RecordType = UserAccountRecord
  override type DaoType    = UserAccountDao.type
  override protected val dao: UserAccountDao.type = UserAccountDao

  override protected def convertToAggregate: UserAccountRecord => RIO[UserAccount] = { record =>
    ReaderT { _ => // このメソッド内部でDBを利用したり、非同期タスクを実行する可能性もあるので、RIO形式を取っている
      Task.pure {
        UserAccount(
          id = UserAccountId(record.id),
          status = Status.withName(record.status),
          emailAddress = EmailAddress(record.email),
          password = HashedPassword(record.password),
          firstName = record.firstName,
          lastName = record.lastName,
          createdAt = record.createdAt,
          updatedAt = record.updatedAt
        )
      }
    }
  }

  override protected def convertToRecord: UserAccount => RIO[UserAccountRecord] = { aggregate =>
    ReaderT { _ =>
      Task.pure {
        UserAccountRecord(
          id = aggregate.id.value,
          status = aggregate.status.entryName,
          email = aggregate.emailAddress.value,
          password = aggregate.password.value,
          firstName = aggregate.firstName,
          lastName = aggregate.lastName,
          createdAt = aggregate.createdAt,
          updatedAt = aggregate.updatedAt
        )
      }
    }
  }

}

DaoはSkinnyDaoSupport#Daoを実装する形式になります。これはSkinnyCRUDMapperの派生型です。 僕の場合は、ここで紹介した方法でスキーマから自動生成しています。

package skinny {

  import com.github.j5ik2o.dddbase.skinny.SkinnyDaoSupport
  import scalikejdbc._
  import _root_.skinny.orm._

  trait UserAccountComponent extends SkinnyDaoSupport {

    case class UserAccountRecord(
        id: Long,
        status: String,
        email: String,
        password: String,
        firstName: String,
        lastName: String,
        createdAt: java.time.ZonedDateTime,
        updatedAt: Option[java.time.ZonedDateTime]
    ) extends Record

    object UserAccountDao extends Dao[UserAccountRecord] {

      override def useAutoIncrementPrimaryKey: Boolean = false

      override val tableName: String = "user_account"

      override protected def toNamedValues(record: UserAccountRecord): Seq[(Symbol, Any)] = Seq(
        'status     -> record.status,
        'email      -> record.email,
        'password   -> record.password,
        'first_name -> record.firstName,
        'last_name  -> record.lastName,
        'created_at -> record.createdAt,
        'updated_at -> record.updatedAt
      )

      override def defaultAlias: Alias[UserAccountRecord] = createAlias("u")

      override def extract(rs: WrappedResultSet, s: ResultName[UserAccountRecord]): UserAccountRecord =
        autoConstruct(rs, s)

    }

  }

}

利用するときは、以下のような感じでリポジトリが返すReaderT[Task, DBSession, UserAccount]#runDBSessionを渡すとTaskが返ってきます。 それをrunAsyncするとFuture[UserAccount]が取得できます。他の実装でもほとんど同じように使えます。ご参考までに。

val resultFuture: Future[UserAccount] = (for {
  _ <- repository.store(userAccount)
  r <- repository.resolveById(userAccount.id)
} yield r).run(AutoSession).runAsync