(Scala前提の記事なので注意してください)
たとえばこんなモデルがあって、相互に依存しているケースを考えよう。
注意:説明を簡単にするために、varを利用しています。
従業員
class Employee( val id: Long, val name: String, var department: Option[Department] = None )
部署
class department( val id: Long, val name: String, var employees: Seq[Employee] = Seq.empty )
利用例
val employee = new Employee(1, "KATO") val department = new Department(1, "Dev") employee.department = Some(Department) // (1) department.employees = Department.employees + employee // (2)
加藤という従業員が開発部に所属する状態を表しています。 これの何が問題かってわかりますか?
(1)と(2) が行われて正しい状態となるわけで、どちらか一方が欠けると不正な状態を生み出します。相互依存な関連はそれだけ取り扱いがシビアだということです。
DDD本では次のように説明があります。
エリックエヴァンスのドメイン駆動設計P81 関連 関係性をできる限り制限することが重要だ。双方向の関連があるということは、両方のオブジェクトが一緒になった時にしか理解できないということを意味している。アプリケーションの要件が双方向に辿ることを求めないなら、一方向にだけ辿るようにすることで、相互依存関係が減り、設計がシンプルになる。ドメインを理解することで、本来はどちらの方向性が重要で、あるのかが、明らかになる場合がある。
DDDでは"本質的な関連だけに絞る"ことを推奨しています。たとえば、従業員の所属部署を利用できればよく、部署が所属従業員を知る必要がない場合は、departmentからEmployeeへの関連は廃止することができるでしょう。つまるところ、便利なだけの関連は控えるとか、要求に応じて関連を取捨選択をすることになるでしょう。
varの属性をvalに変更するとどうなるか
上記の例は関連がvarでした。Scalaではこういう設計は避けましょう。Scalaらしくvalにするとどうなるでしょうか。
従業員
class Employee( val id: Long, val name: String, val department: Option[Department] = None ) { def copy(name: String = this.name, department: Option[Department] = this.depertment) = new Employee(id, name, department) }
部署
class department( val id: Long, val name: String, val employees: Seq[Employee] = Seq.empty ) { def copy(name: String = this.name, employees: Seq[Employee] = this.employees) = new department(name, employees) }
利用例
val employee = new Employee(1, "KATO") val department = new Department(1, "Dev") val newEmployee = employee.copy(department = Some(Department)) // (1) val newdepartment = Department.copy(employees = depertment.employees + newEmployee) // (2) val newNewEmployee = employee.copy(department = Some(newDepartment)) // (3) どこまでも続く...
どちらのクラスもvar属性がなく、同一インスタンスで状態を変更できない不変クラスとして実装しています。新しい状態を作るにはcopyメソッドで新たなインスタンスを作ります。 そうすると結局お互いに保持している参照が古い状態を指していることになるので、参照を新しいものに付け替えなければなりません。 先の例ではvar属性だったため、再代入することで付けかけることができましたが、valになると新しいインスタンスを作らないと無理なのです。 ということで、これはループしてしまうので解決することはできません。そもそもvalだと相互参照の関連の取り扱いに無理があることがわかります。
識別子とリポジトリで関連を実現する方法
上記のクラスは、DDDで定義されるエンティティというモデルです。見分ける、識別する対象のモデルがエンティティです。 名前や住所、年齢などの変化する属性を頼りにしないで、不変な識別子(ID)によって見分ける対象のモデルです。
話を戻します。関連を表現する方法は、もう一つあります。エンティティの参照を保持しないで、識別子(ID)を保持する方法です。 エンティティの参照は、識別子(ID)をリポジトリに与えて、引き当てます。
従業員エンティティ
class Employee( val id: Long, val name: String, val departmentId: Option[Long] = None ) { def copy(name: String = this.name, departmentId: Option[Long] = this.Department) = new Employee(id, name, departmentId) }
部署エンティティ
class department( val id: Long, val name: String, val employeeIds: Seq[Long] = Seq.empty ) { def copy(name: String = this.name, employeeIds: Seq[Long] = this.employeeIds) = new department(name, employeeIds) }
リポジトリ
// リポジトリのトレイト trait EmployeeRepository { def resolveById(id: Long): Employee def store(entity: Employee): Unit } trait departmentRepository { def resolveById(id: Long): department def store(entity: department): Unit } // リポジトリのオンメモリ版実装 class EmployeeRepositoryOnMemory extends EmployeeRepository { ... } class departmentRepositoryOnMemory extends DepartmentRepository { ... }
利用例
val employeeRepository = new EmployeeRepositoryOnMemory val departmentRepository = new DepartmentRepositoryOnMemory val employee = new Employee(1, "KATO") val department = new Department(1, "Dev") val newEmployee = employee.copy(departmentId = Some(Department.id)) // (1) val newdepartment = Department.copy(employeeIds = depertment.employeeIds + employee.id) // (2) employeeRepository.store(newEmployee) // 識別子1の従業員を保存 departmentRepository.store(newDepartment) // 識別子1の部署を保存 // 識別子から部署エンティティを取得する val myDept = departmentRepository.resolveById(employee.DepartmentId) // 識別子から従業員エンティティを取得する val myEmps = department.employeeIds.map{ employeeId => semployeeRepository.resolveById(employeeId) }
エンティティの永続化を担当するのがリポジトリです。ちょうど識別子がキー、エンティティが値のマップのように機能します。それがDBであってもメモリ上であっても同じインターフェイスを介してエンティティのI/Oが行えます。エンティティの参照が欲しくなったら、識別子を基にしてリポジトリから引き当てればよいわけですね。これで相互依存の問題は回避できます。
暗黙的パラメータで表現力を取り戻す
相互依存を解消できたのですが、IDからエンティティを得る変換コードは直感的ではありません。コードの表現力が低下していると言わざるを得ない。 しかも、こういうコードは至る所に出現するので、ドメインサービスなどで定義して楽をしたいところですね。えっ、手続き型に傾倒???。うーん、もう少しなんとかならないものか。
いっその事、エンティティのメソッドの、引数にリポジトリを与えて変換するコードをエンティティが持てばよいのでは?
class Employee( val id: Long, val name: String, val departmentId: Option[Long] = None ) { def copy(name: String = this.name, departmentId: Option[Long] = this.Department) = new Employee(id, name, departmentId) def department(repos: departmentRepository): Option[Department] = repos.resolveById(departmentId) } val employeeRepository = new EmployeeRepositoryOnMemory val myDept = employee.department(employeeRepository)
これでもよいですね。しかし、getterをコールしているイメージがないので、暗黙的パラメータを使うとよいのではないか。
従業員エンティティ
class Employee( val id: Long, val name: String, val departmentId: Option[Long] = None ) { def copy(name: String = this.name, departmentId: Option[Long] = this.Department) = new Employee(id, name, departmentId) def department(implicit repos: departmentRepository): Option[Department] = repos.resolveById(departmentId) }
部署エンティティ
class department( val id: Long, val name: String, val employeeIds: Seq[Long] = Seq.empty ) { def copy(name: String = this.name, employeeIds: Seq[Long] = this.employeeIds) = new department(name, employeeIds) def employees(implicit repos: EmployeeRepository): Set[Employee] = department.employeeIds.map{ employeeId => semployeeRepository.resolveById(employeeId) } }
利用例
implicit val employeeRepository = new EmployeeRepositoryOnMemory implicit val departmentRepository = new DepartmentRepositoryOnMemory val employee = new Employee(1, "KATO") val department = new Department(1, "Dev") val newEmployee = employee.copy(departmentId = Some(Department.id)) // (1) val newdepartment = Department.copy(employeeIds = depertment.employeeIds + employee.id) // (2) employeeRepository.store(newEmployee) // 識別子1の従業員を保存 departmentRepository.store(newDepartment) // 識別子1の部署を保存 // 識別子から部署エンティティを取得する val myDept = employee.department // 識別子から従業員エンティティを取得する val myEmps = department.employees
2つのリポジトリをimplicit val
で定義します。あとはメソッドを呼ぶだけです。引数への割当はコンパイラが解決してくれます。直感的なコードになった。
リポジトリの実装がメモリ上でもDB上でも抽象型のリポジトリとして扱うので動作するはずです。(細かいことですが、リポジトリがメモリだと問題ないのですが、DBなどの場合はI/Oが失敗する可能性があるので、エンティティをTryやEitherにラップして返したり、非同期I/Oの場合はFutureにラップする可能性があります)