追記: 全文公開したのでデータモデリングガイドを参照ください
Repository
まずは単純な社員(employee)について考えます。
idテーブルがひとつ、valueテーブルがひとつという構造です。
はじめはテーブル単位でinsert、selectを行う処理を書き、後でそれをまとめてRepositoryを作ります。
EmployeeDaoを作る
DAO(Data Access Object、以降Daoと表記)とは、データを操作するためのオブジェクトのことです。
テーブルひとつに対し、Daoをひとつ作ります。
まずはEmployeeDaoを作ってみましょう。
private class EmployeeDaoTest {
private val sut = EmployeeDao()
@Test
fun `insertしたデータがselectメソッドで検索できること`() {
val dto = EmployeeDto(
employeeId = UUID.fromString("34a1b832-2220-4075-a704-669c0e97cbbc"),
createdAt = LocalDateTime.now(),
)
sut.insert(dto)
val actual = sut.selectOrNull(dto.employeeId)
assertEquals(dto,actual)
}
}
テストコードをひとつ追加しました。
変数名に使っているsutとは、System Under Testの略でテスト対象システムのことを指しています。
どれがテスト対象になっているのかわかりやすいため、筆者は好んで使っています。
実装の詳細はライブラリーによって変わるためこれ以上立ち入りませんが、createdAtの精度はRDBMSによって変わるためこのテストが成功しない可能性があります。
その場合にはLocalDateTime#nowを直接使うのではなく、ラッパーを用意してそのラッパー内で時間の切り上げを行います。
では、もうひとつテストコードを追加してみましょう。
@Test
fun `同じデータがinsertされた場合は例外を投げること`() {
val dto = EmployeeDto(
employeeId = UUID.fromString("6b4635ba-b868-457b-9109-fe9fc4db1682"),
createdAt = LocalDateTime.now(),
)
sut.insert(dto)
assertThrows<IllegalStateException> {
sut.insert(dto)
}
}
ここではIllegalStateExceptionを期待値としていますが、実際には一意制約違反の例外が発生します。
投げられる例外は利用するライブラリーやツールによっても変わる可能性があるため、仕様化テストという手法を使って記述したほうが良いでしょう。
💡 仕様化テストとは 振る舞いが分からないメソッドに対してテストを書き、テストが失敗したらその振る舞い通りにテストを書き換える手法のことです。 これを行うことで、メソッドの振る舞いをテストコードとして記述することができます。データが存在しなかった場合のテストコードも追加しておきましょう。
@Test
fun `データが存在しなかった場合はnullを返すこと`() {
val notExistingId = UUID.fromString("d6a6205d-728f-4fed-8c0f-e1f36f185f5b")
val actual = sut.selectOrNull(notExistingId)
assertNull(actual)
}
データが存在しなかった場合に例外を投げることも選択肢だと思いますが、Daoのような小さいパーツ単位ではnullを返しておき、Daoを利用する側で投げる例外を変えられるほうがより使い勝手が良くなると考えているため、nullを返しています。
これでEmployeeDaoの実装は終わりです。
EmployeeDaoのシグネチャーはこのようになりました。
fun insert(dto: EmployeeDto)
fun selectOrNull(employeeId: UUID): EmployeeDto?
EmployeeNameDaoを作る
employee_nameテーブルはemployeeテーブルに依存しているため、まずはそれを明示的にするテストを書きましょう。
private class EmployeeNameDaoTest {
private val employeeDao: EmployeeDao = EmployeeDao()
private val sut = EmployeeNameDao(employeeDao)
@Test
fun `employeeIdが存在しない場合は例外を投げること`() {
val notExistingEmployeeId = UUID.fromString("34a1b832-2220-4075-a704-669c0e97cbbc")
val dto = EmployeeNameDto(
employeeId = notExistingEmployeeId,
name = "佐藤",
createdAt = LocalDateTime.now(),
)
assertThrows<IllegalStateException> {
sut.insert(dto)
}
}
}
前述したとおり期待する例外は変える必要がありますが、これで依存関係をテストコードとして記述することができました。
このテストではDaoを直接初期化していますが、DIコンテナを使ってインスタンスの初期化をしても良いです。
次に、最新のレコードを検索するテストを追加してみましょう。
テストが失敗しないように、EmployeeDaoを使って事前にemployeeIdを追加する処理も追加します
companion object {
val addedEmployeeId: UUID = UUID.fromString("67b789c1-3f36-4300-9219-da3f18f22c16")
val addedEmployeeDto = EmployeeDto(
employeeId = addedEmployeeId,
createdAt = LocalDateTime.now(),
)
}
@BeforeEach
fun setup() {
employeeDao.insert(addedEmployeeDto)
}
@Test
fun `insertしたデータがselectメソッドで検索できること`() {
val dto = EmployeeNameDto(
employeeId = addedEmployeeId,
name = "佐藤",
createdAt = LocalDateTime.now(),
)
sut.insert(dto)
val actual = sut.selectLatestOrNull(addedEmployeeId)
assertEquals(dto,actual)
}
}
employee_nameはvalueテーブルのため、idひとつにつき複数のvalueがinsertされます。
最新の一件のみを検索する、というテストも追加してみましょう。
@Test
fun `複数件insertされていた場合、selectメソッドで最新のデータが返ってくること`() {
val dto1 = EmployeeNameDto(
employeeId = addedEmployeeId,
name = "佐藤",
createdAt = LocalDateTime.of(2020, 1, 1, 1, 1),
)
sut.insert(dto1)
val dto2 = EmployeeNameDto(
employeeId = addedEmployeeId,
name = "田中",
createdAt = LocalDateTime.of(2020, 1, 1, 1, 2),
)
sut.insert(dto2)
val actual = sut.selectLatestOrNull(addedEmployeeId)
assertEquals(dto2, actual)
}
createdAtをLocalDateTime#nowを使わずに明示的に宣言していますが、テストが成功するのであればLocalDateTime#nowを使っても構いません。
プロダクトコードではLocalDateTime#nowを頻繁に利用するはずなので、テストコードでも出来る限りプロダクトコードと同じような記述をしたほうが良いでしょう。
ただし、LocalDateTime#nowを使うことによってテストが壊れやすくなる可能性もあります。(このような壊れやすいテストのことはFlaky Testと呼びます)
まずはLocalDateTime#nowを使い、テストが失敗するようになったら、失敗の原因によってはLocalDateTime#ofを使う、というやり方が良いでしょう。
最新の一件を取得するコードは書けましたが、特定の日時時点のデータを取得するselectメソッドも必要です。
それでは、特定の日時時点のデータを取得する、というテストも追加してみましょう。
@Test
fun `複数件insertされており、かつ日時の指定をした場合、selectメソッドで指定された日時時点の最新のデータが返ってくること`() {
val dto1 = EmployeeNameDto(
employeeId = addedEmployeeId,
name = "佐藤",
createdAt = LocalDateTime.of(2020, 1, 1, 1, 1),
)
sut.insert(dto1)
val dto2 = EmployeeNameDto(
employeeId = addedEmployeeId,
name = "田中",
createdAt = LocalDateTime.of(2020, 1, 1, 1, 2),
)
sut.insert(dto2)
val dto3 = EmployeeNameDto(
employeeId = addedEmployeeId,
name = "中村",
createdAt = LocalDateTime.of(2020, 1, 1, 1, 3),
)
sut.insert(dto3)
val searchDateTime = dto3.createdAt.minusSeconds(1)
val actual = sut.selectOrNull(
employeeId = addedEmployeeId,
time = searchDateTime,
)
assertEquals(dto2, actual)
}
テストコードの行数が長くなってしまいましたが、やっていることは複数件のinsertと、指定した日時時点の最新の一件を取得する、ということだけです。
後は、データが存在しなかった場合のテストも追加しましょう。
@Test
fun `データが存在しなかった場合はnullを返すこと`() {
val notExistingId = UUID.fromString("d6a6205d-728f-4fed-8c0f-e1f36f185f5b")
val actual = sut.selectLatestOrNull(notExistingId)
assertNull(actual)
}
これでEmployeeNameDaoの実装は終わりです。
EmployeeDaoFacadeを作る
Daoを作ってきましたが、これらのDaoをまとめるためにFacadeパターンを使います。
EmployeeDaoとEmployeeNameDaoをまとめるEmployeeDaoFacadeを作っていきます。
💡 今回はvalueテーブルがひとつしかありませんが、実際にデータモデリングを行うとvalueテーブルがいくつもあるのが普通です。 複数のDaoを毎回呼び出すのは手間が掛かるため、Facadeパターンを使って処理をまとめたほうが、後々Repositoryを作るのが簡単になります。Daoではselectとinsertしか使いませんでしたが、これはイミュータブルデータモデル特有のものです。
DaoFacadeではイミュータブルデータモデルかミュータブルデータモデルかを意識しないシグネチャーにしたいので、updateとdeleteも追加します。
deleteを追加するにあたって、employeeの状態を管理するテーブルが必要になります。
EmployeeDaoFacadeを作る前にまずはこれを作りましょう。
enum class EmployeeStatus {
DELETED,
NOT_DELETED,
;
}
テーブルは下記のような構造になります。
employee_statusもemployee_nameと同じvalueテーブルです。
statusは引退と現役しかありませんが、今後追加していくことも可能です。
ですが、今は引退と現役のみで作っていきます。
EmployeeStatusDaoを作る
基本的にはEmployeeNameDaoと同じ作りになります。
private class EmployeeStatusDaoTest {
private val employeeDao: EmployeeDao = EmployeeDao()
private val sut = EmployeeStatusDao(employeeDao)
@BeforeEach
fun setup() {
employeeDao.insert(addedEmployeeDto)
}
@Test
fun `insertしたデータがselectメソッドで検索できること`() {
val dto = EmployeeStatusDto(
employeeId = addedEmployeeId,
status = EmployeeStatus.DELETED,
createdAt = LocalDateTime.of(2020, 1, 1, 1, 1),
)
sut.insert(dto)
val actual = sut.selectLatestOrNull(addedEmployeeId)
assertEquals(dto, actual)
}
@Test
fun `複数件insertされていた場合、selectメソッドで最新のデータが返ってくること`() {
val dto1 = EmployeeStatusDto(
employeeId = addedEmployeeId,
status = EmployeeStatus.DELETED,
createdAt = LocalDateTime.of(2020, 1, 1, 1, 1),
)
sut.insert(dto1)
val dto2 = EmployeeStatusDto(
employeeId = addedEmployeeId,
status = EmployeeStatus.DELETED,
createdAt = LocalDateTime.of(2020, 1, 1, 1, 2),
)
sut.insert(dto2)
val actual = sut.selectLatestOrNull(addedEmployeeId)
assertEquals(dto2, actual)
}
@Test
fun `複数件insertされており、かつ日時の指定をした場合、selectメソッドで指定された日時時点の最新のデータが返ってくること`() {
val dto1 = EmployeeStatusDto(
employeeId = addedEmployeeId,
status = EmployeeStatus.DELETED,
createdAt = LocalDateTime.of(2020, 1, 1, 1, 1),
)
sut.insert(dto1)
val dto2 = EmployeeStatusDto(
employeeId = addedEmployeeId,
status = EmployeeStatus.NOT_DELETED,
createdAt = LocalDateTime.of(2020, 1, 1, 1, 2),
)
sut.insert(dto2)
val dto3 = EmployeeStatusDto(
employeeId = addedEmployeeId,
status = EmployeeStatus.DELETED,
createdAt = LocalDateTime.of(2020, 1, 1, 1, 3),
)
sut.insert(dto3)
val searchDateTime = dto3.createdAt.minusSeconds(1)
val actual = sut.selectOrNull(
employeeId = addedEmployeeId,
time = searchDateTime,
)
assertEquals(dto2, actual)
}
@Test
fun `データが存在しなかった場合はnullを返すこと`() {
val notExistingId = UUID.fromString("6096d57c-4308-423f-a56e-641f36a76ffb")
val actual = sut.selectLatestOrNull(notExistingId)
assertNull(actual)
}
@Test
fun `employeeIdが存在しない場合は例外を投げること`() {
val notExistingEmployeeId = UUID.fromString("262f5554-f0eb-4689-913c-b8c0868d7d0e")
val dto = EmployeeStatusDto(
employeeId = notExistingEmployeeId,
status = EmployeeStatus.DELETED,
createdAt = LocalDateTime.now(),
)
assertThrows<IllegalStateException> {
sut.insert(dto)
}
}
companion object {
val addedEmployeeId: UUID = UUID.fromString("7d2830ec-07fa-4e01-8882-97d67f0db97a")
val addedEmployeeDto = EmployeeDto(
employeeId = addedEmployeeId,
createdAt = LocalDateTime.now(),
)
}
}
ここで注意したいのが、employee_statusテーブルは差分が無くてもinsertを行います。
何故なら、statusが変わっていなくてもnameのような他のvalueが書き換わっている可能性があるからです。
これでEmployeeStatusDaoの実装は終わりです。
EmployeeStatusDaoができたので、今度こそEmployeeDaoFacadeを作っていきます。
EmployeeDaoFacadeのinputとoutputはドメインモデルであるEmployeeを使います。
data class Employee(
val employeeId: UUID,
val name:String,
)
ドメインモデリングをしていないため振る舞いを持っていませんが、このようなドメインモデルがあると仮定して実装していきます。
まずは簡単なテストコードを追加してみます。
@Test
fun `追加した社員が検索できること`() {
val employeeId = UUID.fromString("69b8336d-c634-43df-8414-40c6bda10a06")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.insert(employee)
val actual = sut.selectBy(employeeId)
assertEquals(employee, actual)
}
insertする単位がdtoではなくドメインオブジェクトになりましたが、やることは基本同じです。
次に、データを更新した場合、最新のデータが検索できること、というテストを追加します。
@Test
fun `データを更新した場合、最新のデータが検索できること`() {
val employeeId = UUID.fromString("3e921828-3706-4b5f-844c-20dd0fd36603")
val employee1 = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.insert(employee1)
val employee2 = employee1.copy(
name = "田中",
)
sut.update(employee2)
val actual = sut.selectBy(employee1.employeeId)
assertEquals(employee2, actual)
}
このとき、employeeテーブルに既に同じidが存在している場合にはinsertしません。
更に、employee_nameは値が同じであればinsertしません。
これは差分insertをしているためです。
今回からinsertに加えてupdateが出てきたため、これに関するテストも必要です。
まずは、既にinsertしている社員の場合には例外を投げる、というテストを追加します。
@Test
fun `同じ社員を複数回追加しようとした場合は例外を投げる`() {
val employeeId = UUID.fromString("96174a80-bcc5-4c03-a790-8141af563598")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.insert(employee)
assertThrows<IllegalStateException> {
sut.insert(employee)
}
}
これに加え、追加されていない社員をupdateしようとした場合には例外を投げる、というテストも追加します。
@Test
fun `追加されていない社員を更新しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("1a40efdc-3f89-4ab8-b139-6edc55bfd908")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
assertThrows<IllegalStateException> {
sut.update(employee)
}
}
社員がいなかった場合のテストも書きましょう。
@Test
fun `社員が存在しない場合は例外を投げること`() {
val notExistsEmployeeId = UUID.fromString("32d3c6b1-250a-42d2-ad8e-a4ff196cccb6")
assertThrows<IllegalStateException> {
sut.selectBy(notExistsEmployeeId)
}
}
必要かどうかはわかりませんが、サンプルとしてdeleteのテストも書いてみましょう
@Test
fun `削除された社員を検索しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("ce1019c1-0352-475f-8732-99bc54ee28cf")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.insert(employee)
sut.delete(employee.employeeId)
assertThrows<IllegalStateException> {
sut.selectBy(employee.employeeId)
}
}
@Test
fun `削除された社員を更新しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("a00e2b7f-2ea4-464e-a972-3341bf0b4711")
val inserting = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.insert(inserting)
sut.delete(inserting.employeeId)
val updating = Employee(
employeeId = employeeId,
name = "田中",
)
sut.update(updating)
assertThrows<IllegalStateException> {
sut.selectBy(updating.employeeId)
}
}
@Test
fun `削除された社員を追加しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("39fcce89-9e11-45e1-a89c-2ce07cc22334")
val inserting = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.insert(inserting)
sut.delete(inserting.employeeId)
assertThrows<IllegalStateException> {
sut.insert(inserting)
}
}
@Test
fun `存在しない社員を削除しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("10e57335-5f6c-483d-8708-e7df373d29e6")
assertThrows<IllegalStateException> {
sut.delete(employeeId)
}
}
内部的にEmployeeStatusを使うことでEmployeeDaoFacade内で処理を閉じ込めているため、ミュータブルデータモデルでdeleteをしたのと同じ処理を行うことができています。
しかし、状態の数はなるべく少なくしたいため、絶対に必要な場合を除いてdeleteを実装するのはおすすめしません。
代わりに、EmployeeStatusで扱う状態を下記のようにすると良いかもしれません。
enum class EmployeeStatus {
ACTIVE_DUTY, // 現役
RETIRED, // 引退
;
}
このようにするとdeleteのテストが不要になります。
代わりにupdateのテストパターンが少し増えますが、deleteほど複雑ではなくなります。
他にも、statusはドメインモデル用とデータモデル用で別にしたほうが扱いやすいです。
enum class EmployeeStatus {
ACTIVE_DUTY, // 現役
RETIRED, // 引退
;
}
enum class EmployeeStatusDto {
ACTIVE_DUTY, // 現役
RETIRED, // 引退
DELETED,
NOT_DELETED,
;
}
employee_statusテーブルにまとめてデータを入れられるので、EmployeeStatusDtoでドメインモデルのstatusと同じ要素を持っておいたほうが良いです。
EmployeeRepositoryを作る
肝となるEmployeeDaoFacadeは作ったので、これを使ってEmployeeRepositoryを作っていきます。
interface EmployeeRepository {
fun resolveBy(employeeId:UUID):Employee
fun add(employee:Employee)
fun replace(employee:Employee)
fun remove(employeeId:UUID)
}
SQLに依存しないようなシグネチャーにしましたが、やりたいことはDaoFacadeと同じです。
テストコードはEmployeeDaoFacadeTestをほぼそのまま使えます。
private class EmployeeRepositoryTest {
private val employeeDao: EmployeeDao = EmployeeDao()
private val sut = EmployeeRepositoryImpl(
employeeDaoFacade = EmployeeDaoFacade(employeeDao)
)
@Test
fun `追加した社員が検索できること`() {
val employeeId = UUID.fromString("aca88a13-19d6-4255-98e0-3b3f99902b9b")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.add(employee)
val actual = sut.resolveBy(employeeId)
assertEquals(employee, actual)
}
@Test
fun `社員が存在しない場合は例外を投げること`() {
val notExistsEmployeeId = UUID.fromString("4b1056a0-1aa8-4738-b006-5f87f33f6adf")
assertThrows<IllegalStateException> {
sut.resolveBy(notExistsEmployeeId)
}
}
@Test
fun `データを更新した場合、最新のデータが検索できること`() {
val employeeId = UUID.fromString("8915b481-9153-4c11-a254-0484eec0c135")
val employee1 = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.add(employee1)
val employee2 = employee1.copy(
name = "田中",
)
sut.replace(employee2)
val actual = sut.resolveBy(employee1.employeeId)
assertEquals(employee2, actual)
}
@Test
fun `同じ社員を複数回追加しようとした場合は例外を投げる`() {
val employeeId = UUID.fromString("52e77f54-5c80-466e-b05c-e8bca743fc5a")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.add(employee)
assertThrows<IllegalStateException> {
sut.add(employee)
}
}
@Test
fun `追加されていない社員を更新しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("8ee29ce3-e042-4bbe-855d-ad207ecb0b03")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
assertThrows<IllegalStateException> {
sut.replace(employee)
}
}
@Test
fun `削除された社員を検索しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("a7edc254-c410-4866-ad32-0d3132f777e2")
val employee = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.add(employee)
sut.remove(employee.employeeId)
assertThrows<IllegalStateException> {
sut.resolveBy(employee.employeeId)
}
}
@Test
fun `削除された社員を更新しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("10b46997-190a-4ab7-9bbc-ec65414f13e4")
val inserting = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.add(inserting)
sut.remove(inserting.employeeId)
val updating = Employee(
employeeId = employeeId,
name = "田中",
)
sut.replace(updating)
assertThrows<IllegalStateException> {
sut.resolveBy(updating.employeeId)
}
}
@Test
fun `削除された社員を追加しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("8c27d61a-d3d6-4b37-ad7a-7f06fee3dd35")
val inserting = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.add(inserting)
sut.remove(inserting.employeeId)
assertThrows<IllegalStateException> {
sut.add(inserting)
}
}
@Test
fun `削除された社員を削除しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("afd5b57b-03b5-4164-8b49-d2771bbf5fc5")
val inserting = Employee(
employeeId = employeeId,
name = "佐藤",
)
sut.add(inserting)
sut.remove(inserting.employeeId)
assertThrows<IllegalStateException> {
sut.remove(inserting.employeeId)
}
}
@Test
fun `存在しない社員を削除しようとした場合には例外を投げる`() {
val employeeId = UUID.fromString("ffe7a0ec-bb22-493b-94a8-dd0c451f5be7")
assertThrows<IllegalStateException> {
sut.remove(employeeId)
}
}
}
コピペして少し修正しました。
今回はDaoFacadeをひとつしか使わなかったのですが、複数のDaoFacadeを使う場合はRepositoryのテストがかなり重要になってきます。
ふりかえり
簡単なRepositoryを実装することができました。
今回、一番複雑な実装になったEmployeeDaoFacadeのプロダクトコードはこのようになりました。
class EmployeeDaoFacade(
private val employeeDao: EmployeeDao
) {
private var employeeNameDao: EmployeeNameDao = EmployeeNameDao(employeeDao)
private var employeeStatusDao: EmployeeStatusDao = EmployeeStatusDao(employeeDao)
fun insert(employee: Employee) {
validatedEmployeeNotAdded(employee)
employeeDao.insert(
EmployeeDto(
employeeId = employee.employeeId,
createdAt = LocalDateTime.now()
)
)
employeeNameDao.insert(
EmployeeNameDto(
employeeId = employee.employeeId,
name = employee.name,
createdAt = LocalDateTime.now()
)
)
employeeStatusDao.insert(
EmployeeStatusDto(
employeeId = employee.employeeId,
status = EmployeeStatus.NOT_DELETED,
createdAt = LocalDateTime.now()
)
)
}
private fun validatedEmployeeNotAdded(employee: Employee) {
val existing = employeeDao.selectOrNull(employee.employeeId)
if (existing != null) {
throw IllegalStateException("既に追加されている社員です。employeeId => ${employee.employeeId}")
}
}
fun update(employee: Employee) {
insertEmployeeDtoIfNotExists(employee.employeeId)
insertIfNameChanged(employee.employeeId, employee.name)
insertStatusNotDeleted(employee.employeeId)
}
private fun insertStatusNotDeleted(employeeId: UUID) {
val existingStatusDto = employeeStatusDao.selectLatestOrNull(employeeId)
?: throw IllegalStateException("社員のstatusが存在しません。employeeId => $employeeId")
employeeStatusDao.insert(
EmployeeStatusDto(
employeeId = existingStatusDto.employeeId,
status = EmployeeStatus.NOT_DELETED,
createdAt = LocalDateTime.now()
)
)
}
private fun insertIfNameChanged(employeeId: UUID, name: String) {
val existingNameDto = employeeNameDao.selectLatestOrNull(employeeId)
?: throw IllegalStateException("社員の氏名が存在しません。employeeId => $employeeId")
if (existingNameDto.name != name) {
employeeNameDao.insert(
EmployeeNameDto(
employeeId = existingNameDto.employeeId,
name = name,
createdAt = LocalDateTime.now()
)
)
}
}
private fun insertEmployeeDtoIfNotExists(employeeId: UUID) {
val existingEmployeeDto = employeeDao.selectOrNull(employeeId)
if (existingEmployeeDto == null) {
employeeDao.insert(
EmployeeDto(
employeeId = employeeId,
createdAt = LocalDateTime.now()
)
)
}
}
fun selectBy(employeeId: UUID): Employee {
val employeeDto = employeeDao.selectOrNull(employeeId)
?: throwEmployeeNotFound(employeeId)
val nameDto = employeeNameDao.selectLatestOrNull(employeeId)
?: throwEmployeeNotFound(employeeId)
val statusDto = employeeStatusDao.selectLatestOrNull(employeeId)
?: throwEmployeeNotFound(employeeId)
if (statusDto.status == EmployeeStatus.DELETED) {
throwEmployeeNotFound(employeeId)
}
return Employee(
employeeId = employeeDto.employeeId,
name = nameDto.name
)
}
private fun throwEmployeeNotFound(employeeId: UUID): Nothing {
throw IllegalStateException("社員が存在しません。 employeeId => $employeeId")
}
fun delete(employeeId: UUID) {
val existing = employeeStatusDao.selectLatestOrNull(employeeId)
?: throwEmployeeNotFound(employeeId)
if (existing.status == EmployeeStatus.DELETED) {
throwEmployeeNotFound(employeeId)
}
val dto = EmployeeStatusDto(
employeeId = employeeId,
status = EmployeeStatus.DELETED,
createdAt = LocalDateTime.now()
)
employeeStatusDao.insert(dto)
}
}
サンプルコードの簡略化のためにドメイン例外(EmployeeNotFoundException)や専用の値オブジェクト(EmployeeId、EmployeeName)を作りませんでしたが、これらも作ることをおすすめします。
もしRepositoryで受け取るオブジェクトでStringのような基本データ型を使ってしまっていると、各所でバリデーションをやらなければならないのでコードがバリデーションまみれになってしまいます。
他にも、LocalDateTime.now()を直接使うのではなく専用のサービスでラップしたほうが受入テストで時間をmockする時に便利です。
依存関係のあるRepository
社員Repositoryにのみ焦点を当てて実装方法を見てきましたが、次は依存関係のある複数のRepositoryについて見ていきます。
まずは下記を見てください。
社員、部署、配属をモデリングしたものです。
ここで「配属理由」というvalueテーブルを追加したいとします。
するとこのようになりました。
配属理由valueテーブルがemployee_idとcompany_idのふたつのidに依存するようになってしまいました。
「valueテーブルはひとつのidテーブルにしか依存しない」というルールを破ってしまいました。
では、配属理由テーブルが配属テーブルに依存するように修正してみましょう。
配属テーブルに配属idを追加し、配属理由テーブルが配属idに依存するようにしました。
これをすることで「idテーブルでは依存関係を表現する」「valueテーブルでは値を表現する」という整理をすることができました。
idテーブルとvalueテーブルのルールを守ると、このようにデータの責務が明確になり、凝縮性が高まります。
データモデリングはこれで一旦終わりにし、Repositoryを作っていきます。
ライフサイクルごとにRepositoryを作る
ライフサイクルとは、それが生まれてから死ぬまでの期間のことです。
プログラミングの業界では特にインスタンスの生成から破棄までの期間のことを言います。
イミュータブルデータモデルではデータの破棄はしないため、データの生成に焦点を当てます。
簡単に言うと、idを生成する単位でRepositoryを作っていきます。
interface EmployeeRepository {
fun resolveBy(employeeId: UUID): Employee
fun add(employee: Employee)
fun replace(employee: Employee)
fun remove(employeeId: UUID)
}
data class Employee(
val employeeId: UUID,
val name: String
)
interface DepartmentRepository {
fun resolveBy(departmentId: UUID): Department
fun add(department: Department)
fun replace(department: Department)
fun remove(departmentId: UUID)
}
data class Department(
val departmentId: UUID,
)
interface AssignmentRepository {
fun resolveBy(assignmentId: UUID): Assignment
fun add(assignment: Assignment)
fun replace(assignment: Assignment)
fun remove(assignmentId: UUID)
}
data class Assignment(
val assignmentId:UUID,
val employeeId: UUID,
val departmentId:UUID,
val reason:String,
)
DepartmentRepositoryはEmployeeRepositoryと同じ構造になるため、ここではAssignmentRepositoryに注目してみましょう。
resolveByでassignmentIdを受け取っていますが、そのようなユースケースは存在するでしょうか。
「配属idで配属した人と部署が知りたい」のようなユースケースが存在する場合は必要ですが、配属idで検索しないのであれば不要です。
代わりにこのようなシグネチャーにします。
fun resolveBy(employeeId:UUID,departmentId:UUID):Assignment
fun remove(employeeId:UUID,departmentId:UUID)
もしドメインモデルとして配属idというものが出てこないのであれば、データモデル内で完結させるために、Assignmentオブジェクトも変更したほうが良いでしょう。
// assignmentIdを削除
data class Assignment(
val employeeId: UUID,
val departmentId:UUID,
val reason:String,
)
このような、データモデル都合で生成したidのことをヘルパーidと呼びます。
ヘルパーid
ヘルパーidというのはデータモデルをシンプルにするために使われるものであって、検索時のキーとして使われるものではありません。
そのため、Repositoryのシグネチャーにヘルパーidが出てくることはありません。
AssignmentRepositoryでemployeeIdとdepartmentIdを受け取る形にしているため、データの依存関係も表現できています。
複雑な依存関係のあるRepository
前節ではシンプルな依存関係のRepositoryを見てきましたが、もっと複雑な依存関係を持っていることも多々あります。
上記は処理順を表現しています。
依存関係は処理順の逆になります。
見てもらえるとわかる通り、未来が過去に依存するようになっています。
このルールを守ってモデリングをしていきます。
これにvalueテーブルを追加するとデータモデリングは終了です。
後はそれぞれのRepositoryを作ればokです。
💡 今回は例として直前のidに依存する形でデータモデリングしましたが、恐らくorder_idを使いまわすのが適切でしょう。 ドメインモデルに出てこないidはヘルパーidとして定義しておきます。このように、idに焦点を当てると簡単にデータモデリングをすることができます。
idの単位でデータモデリングができるとRepositoryを作る単位もわかるので、ドメインモデリングに悩んだら一度データモデリングをしてみるのも手かもしれません。
同じライフサイクルの役割が違うRepository
SNSのユーザー情報について考えてみましょう。
ライフサイクルはほぼ同じですが、属性によって役割が違います。
公開しない情報
- 本名
- メールアドレス
- 住所
これらは他人には知られたくない情報です。
お知らせメールを送ったり、郵便物を届ける時に使います。
公開する情報
- ニックネーム
- プロフィール
- プロフィール画像
これらは他人に知られても良い情報です。
SNSでやりとりをしたり、自分がどんな人なのかを紹介するときに使います。
公開してもしなくてもいい情報
- 誕生日
- 利用開始日
これらは任意で公開できる内容です。
SNSでやりとりをするきっかけに使えます。
このように、属性によって役割(ユースケースや公開有無)が違う場合、Repositoryも役割単位で作ったほうが良いでしょう。
interface UserPublicInformationRepository //公開する情報
interface UserNonPublicInformationRepository //非公開の情報
interface UserVoluntaryPublicInformationRepository //任意で公開できる情報
上記のようにRepositoryを作った場合、ユーザーidをどのRepositoryで管理するのか、という問題が出てきます。
データモデルはidテーブルひとつにvalueテーブルが複数存在していて、Repositoryによって扱うvalueテーブルが違う、という状態になっています。
このような場合は、ユーザーidを扱う専用のRepositoryを作ると良いでしょう。
interface UserIdRepository
id専用のRepositoryを作ることで、ユーザーの情報がほとんど無い状態でもユーザーを作成することができるようになります。
UserIdRepositoryを呼び出すコードは下記のようになります。
@Service
class CreateUserId(
private val userIdRepository: UserIdRepository,
) {
fun handle(): UUID {
val userId = UUID.randomUUID()
userIdRepository.add(userId)
return userId
}
}
これをすることで、、公開する情報、非公開の情報、任意で公開できる情報を順不同で保存することができるようになります。
もっと極端に順不同でのデータの保存を達成したい場合、属性ごとにRepositoryを作ると更に特化することができます。
interface UserRealNameRepository
interface UserNickNameRepository
interface UserMailAddressRepository
...
このやり方は、データを登録・更新・削除するときの単位が分割されている程効果を発揮します。
//ユーザーの本名を登録するユースケース
@Service
class RegisterRealName(
private val userIdRepository: UserIdRepository,
private val realNameRepository: UserRealNameRepository,
) {
fun handle(input: RealNameRegisterInputData) {
val userId = input.userId
userIdRepository.validatedExists(userId)
realNameRepository.add(
userId = userId,
realName = input.realName,
)
}
}
data class RealNameRegisterInputData(
val userId: UUID,
val realName: String,
)
//ユーザーの本名を更新するユースケース
@Service
class UpdateRealName(
private val userRealNameRepository: UserRealNameRepository
) {
fun handle(input: RealNameUpdateInputData) {
userRealNameRepository.replace(
userId = input.userId,
realName = input.realName,
)
}
}
data class RealNameUpdateInputData(
val userId: UUID,
val realName: String
)
//ユーザーの本名を削除するユースケース
@Service
class DeleteRealName(
private val userRealNameRepository: UserRealNameRepository
) {
fun handle(input: RealNameRemoveInputData) {
userRealNameRepository.remove(input.userId)
}
}
data class RealNameRemoveInputData(
val userId: UUID,
)
このように分割していくと特化したユースケースを書くことができ、コードがよりシンプルになります。
特に、属性によってライフサイクルを分けたい場合にこのパターンは使えます。
一度に扱うデータが多ければ多いほどユースケースは複雑になり、コードが煩雑になっていきます。
サービスが成長していきユースケースやコードが複雑になった場合はこのようにコードを分割することをおすすめします。
状態ごとのRepository
試験について考えてみましょう。
次に、受験生の目線で考えてみましょう。
試験を開催する側と受験する側でモデルが違います。
つまり「アクターによってユースケースが違う」モデルです。
モデルが違うのであれば、勿論Repositoryも分割します。
interface PrimarySelectionRepository
interface SecondarySelectionRepository
interface FinalSelectionRepository
問題になるのは受験生のモデルです。
今回はモデルひとつにつきひとつのRepositoryを作りました。
interface ExamineeInPrimarySelectionRepository
interface PassedPrimarySelectionRepository
interface FailedPrimarySelectionRepository
interface ExamineeInSecondarySelectionRepository
interface PassedSecondarySelectionRepository
interface FailedSecondarySelectionRepository
interface ExamineeInFinalSelectionRepository
interface PassedFinalSelectionRepository
interface FailedFinalSelectionRepository
このように、状態ごとにRepositoryを作るパターンもあります。
特に、状態遷移に制約がある場合には有効です。
💡 一次選考を通過しないと二次試験は受験できない、のような、状態遷移をするための条件があることを**制約**と呼んでいます。状態とは
受験生のモデルでは試験をするごとに通過又は落選し、次の試験がある場合は受験していました。
受験している人は同じ人なので受験idは同じものを使いまわしたほうが良いでしょう。(順不同の属性を持っている場合にはExamineeIdRepositoryを作る可能性もあります)
このような、同一性があるものの、タイミングによって持っているデータが変わるようなことを状態と呼んでいます。
一方で、試験のモデルでは一次試験、二次試験、最終試験を扱っていましたが、これは状態と呼べるでしょうか。
これは注目する物事によって状態になったり、ならなかったりします。
「2020年度の試験」という考え方をするのであれば「2020年度の試験の一次試験」、「2020年度の試験の二次試験」となり、状態と呼んでも良いでしょう。
この場合は試験idは同じものを使います。
同一性の無い「試験」という考え方をするのであれば「1回目の試験」、「2回目の試験」となり、これは状態とは呼べず、全く別のものとして扱います。このようなもののことを種別と呼びます。
この場合は試験idは別のものを使います。
種別が違うのであればRepositoryを分けたほうが良いでしょう。
状態が違い、制約がある場合もRepositoryを分けたほうが良いでしょう。
同じ種別で状態遷移の制約が無い場合はRepositoryを分ける必要はあまりありません。
statusテーブル
状態ごとにRepositoryを分割した場合、状態ごとにテーブルを作るパターンとstatusテーブルを作るパターンがあります。
状態ごとにテーブルを作るパターン
※同一性があるためselectionテーブルに依存する形になっています。
statusテーブルを作るパターン
どちらの方法でも問題なく実装できます。
ですが個人的にはstatusテーブルを作ることが多いです。
種別ごとにテーブルを作るパターン
※同一性が無いため独自のidを持っています。
種別が違うのであれば、データモデルでも全く別のものとして扱います。
まとめ
Dao、DaoFacade、Repositoryの実装方法とその利用方法を見てきました。
idに注目してデータモデリングすることでvalueの持っている余計な情報に振り回されずに、依存関係のみに集中して考えることができるようになります。
更に、役割の単位、データの単位、状態の単位、種別の単位でRepositoryを分割することによって扱いやすくなる例も見てきました。
データモデリングはid、依存関係、ライフサイクルを発見するのに優れています。
基本的にはドメインモデリング後にデータモデリングすることをおすすめしていますが、考えが詰まったり、違和感を覚えるドメインモデルが出てきたらデータモデリングをしてみると新しい発見があるかもしれません。
どちらにしても、小さいサイズでモデリングすることをおすすめします。