ContractS開発者ブログ

契約マネジメントシステム「ContractS CLM」の開発者ブログです。株式会社HolmesはContractS株式会社に社名変更しました。

ArchUnitがDDDのモジュール実現にちょうど良かった話

この記事は Holmes Advent Calendar 2020 - Qiita 11 日目の記事です。


こんにちは。エンジニアの友野です。最近、Quartoにハマっており、チーム内普及に勤しんでいます。

先日、ドメイン駆動設計(以降、DDD)初学者にありがちな

  • 「これはどこに置くんだっけ?」
  • 「このクラスからドメイン層呼んでいいんだっけ?」

というような不安を払拭するために良い方法はないものかと悩んでいたところ、前回のJJUGでArchUnitを知りました。 早速プロダクト適用してみたら、結構良かったのでまとめておきます。

これから例示するコードは以下の動作環境*1で作り、動かしています。

JVM: OpenJDK Runtime Environment Corretto-8.252.09.1 (build 1.8.0_252-b09)
Gradle: 4.10.3
Kotlin: 1.3.41
Spock: spock-spring:1.3-groovy-2.4

ArchUnitとは

ArchUnitは、Java/Kotlinで書かれたアプリケーションのアーキテクチャを検証するためのライブラリです。パッケージやクラスなどの依存関係チェックができます。詳しくは公式サイトをご参照ください。

www.archunit.org

Holmesでは一部アプリケーションをKotlinで書いているので、JavaだけでなくKotlinもテストできるのは嬉しいですね。 詳細なセットアップ手順やAPIは読みやすいドキュメントが公式から提供されているので、割愛します。

www.archunit.org

Why ArchUnit

改めて整理するとやりたいことは、以下の2点です。

  • 参照関係を明確化して責務分離を進める
  • データモデルなどの既存資産とドメインモデルを分離する

パッケージ構造や参照関係のような決め事を守るために、最も簡単な始め方はレビュープロセスで担保することです。 しかし、ご存知の通り、この方法は強制力がなかったり、見逃しがあったり、適切に運用するのは難しいです。

仕組みで守る方法として、gradleでサブプロジェクト化して依存の方向をdependenciesで定義することも考えられますが、新規プロジェクトならいざ知らず、既存資産があるとこのアプローチに舵を切るのは若干尻込みしてしまいます。

そのちょうど中間として、現在の構造を大きく変えずに、かつテストコードによって比較的強い制約として表現できるArchUnitはまさに我々のような状況には最適と言えます。

セットアップ

今回対象としているアプリケーションはkotlinでプロダクションコードを書き、テストコードはgroovy(spock)で書いています。 それぞれのバージョンは冒頭で記載した通りです。 テストフレームワークにspockを採用しているので、build.gradleの依存には無印のものを追加します。

dependencies {
    // other dependencies...
    testImplementation 'com.tngtech.archunit:archunit:0.14.1'
}

spockテストクラスも書いておきます。

class ArchitectureSpec extends Specification {

    final def BASE_PACKAGE = "com.example"
    final String[] LEGACIES = [
        "..entity..",
        "..repository..",
        "..service.."]
    final def CONTROLLER = "..controller.."
    final def USE_CASE = "..usecase.."
    final def DOMAIN = "..domain.."
    final def INFRASTRUCTURE = "..infrastructure.."

    final JavaClasses importedClasses =
            new ClassFileImporter().importPackages(BASE_PACKAGE)

    // ここにテストケースを書く
}

これで準備が整いました。

守りたいアーキテクチャ

先日の記事で触れた三層+ドメインオブジェクトアーキテクチャから試行錯誤をして、今のところオニオンアーキテクチャに近い形に落ち着いています。

tech.holmescloud.com

f:id:a-tomono:20201210231738p:plain
簡略化したアーキテクチャ構成図

参照関係の明確化

ユースケース層はコントローラ層から参照され、ドメイン層はユースケース層およびインフラストラクチャ層から参照されます。

def "ユースケース層はコントローラ層からのみ呼び出される"() {
    given:
    ArchRule rule = classes()
        .that().resideInAPackage(USE_CASE)
        .should().onlyBeAccessed().byAnyPackage(CONTROLLER, USE_CASE)

    expect:
    rule.check(importedClasses)
}

def "ドメイン層はユースケース層、インフラストラクチャ層からのみ呼び出される"() {
    given:
    ArchRule rule = classes()
        .that().resideInAPackage(DOMAIN)
        .should().onlyBeAccessed().byAnyPackage(
            USE_CASE,
            DOMAIN,
            INFRASTRUCTURE,
        )

    expect:
    rule.check(importedClasses)
}

これを実行すると…、テストに失敗しました。

...
    at com.example.ArchitectureSpec.ドメイン層はユースケース層、インフラ層からのみ呼び出される(ArchitectureSpec.groovy:47)
Caused by: java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package '..domain..' should only be accessed by any package ['..usecase..', '..domain..', '..infrastructure..']' was violated (9 times):

既存資産のモデル変換は、そのモデル自身にさせていたため、インフラストラクチャ層以外から参照していることを思い出しました。 これは既存資産を活かすことを優先した結果でもあるので、広義でのインフラストラクチャとして扱います。既存資産のモデルを参照元に追加して再実行、テスト成功しました。

final def LEGACY_MODELS = "..entity.."

def "ドメイン層はユースケース層、インフラストラクチャ層からのみ呼び出される"() {
    given:
    ArchRule rule = classes()
        .that().resideInAPackage(DOMAIN)
        .should().onlyBeAccessed().byAnyPackage(
            USE_CASE,
            DOMAIN,
            INFRASTRUCTURE,
            LEGACY_MODELS, // 追加
        )

    expect:
    rule.check(importedClasses)
}

既存資産とドメインモデルの分離

新しいドメインモデルを定義しているので、既存資産に依存はさせたくありません。つい、既存サービスクラスをDIすればもっと早く実装できるのに、という誘惑に駆られますが、これはファットなドメインサービスを生み出し、結果としてドメイン貧血症を引き起こします。なによりビジネスルールが散在したままです。 これを避けるためにテストケースを追加します。

def "ドメイン層は既存資産を利用しない"() {
    given:
    ArchRule rule = noClasses()
        .that().resideInAPackage(DOMAIN)
        .should().dependOnClassesThat().resideInAnyPackage(LEGACIES)

    expect:
    rule.check(importedClasses)
}

これで最低限の労力でやりたいことは満たせたと思います。

ふりかえり

今回のテストを書いていく中で、偶然にも、コントローラがドメインオブジェクトを参照している実装を発見できました。レビュー済みの箇所ではありましたが、やはり見落としはあるようです。 テストでアーキテクチャの制約を表現できるので、構造を頭に入れつつも、個々の機能実装に集中できる感覚がつかめました。

現在はコントローラからのドメインオブジェクト参照は禁止していますが、IDクラスなど、制約を部分的に緩くするパターンも今後考えられます。これは、例えば、特定のインタフェースを実装した値オブジェクトのみ許可する、というようなテストで簡単に実現できそうです。 ただし、なぜこの制約があるのかはしっかりと共有しないとルールを守ることがゴールになり、目的と手段が入れ替わってしまう懸念もあります。

まとめ

スタートしたばかりのDDDにArchUnitで適度な制約を設けて、開発者がモデリングとそのコード表現にフォーカスできている状態に一歩近付きました。ArchUnitはユーザーガイドがよくできているので、色々読みながらプロダクト適用していこうと思います。

Holmesでは、今後もDDDを活用してプロダクトを成長させ、契約に関する顧客課題解決を通じた価値提供をしていきます。 興味がある方はご連絡下さい。

lab.holmescloud.com


明日は、同じチームでスクラムマスターをしている吾郷さんによる「スプリントレビュー改善の記録」です 。

*1:2019年に開発したサービスの環境で試しているため、バージョンは少し古いものです