タケハタのブログ

プログラマの生き方、働き方、技術について雑多に書いていくブログです。

GradleのマルチプロジェクトによるKotlin、Spring Bootでのオニオンアーキテクチャの実現

4月に発売した書籍「Kotlin サーバーサイドプログラミング実践開発」なのですが、この中で途中まで作っていてボツネタにした内容がありました。

gihyo.jp

それが「Gradleのマルチプロジェクトでオニオンアーキテクチャを実現する」というものです。
第2部で作成していたbook-managerというアプリケーションは、もともとこれを使って作成していましたが、途中でやめて現在の形になりました。

github.com

ボツネタにした理由としては、一回実践で導入してみていくつか微妙な点があったことと、紙面上の説明が複雑になるのでベーシックな内容としては外していいかなと思ったためです。
ただせっかく途中まで作っていたので、試して微妙と感じた点も含めて、今回紹介したいと思います。

サンプルとしてこのbook-managerの内容をマルチプロジェクト化したアプリケーションを使い、オニオンアーキテクチャを実現した例を解説していきます。
本記事ではオニオンアーキテクチャを使用して説明していますが、クリーンアーキテクチャなどを使用する場合も同様の方法で実現できます。

サンプルコード

今回のサンプルコードは以下に公開してあるので、こちらもぜひご活用ください。

https://github.com/n-takehata/kotlin-onion-architecture-example-with-springboot-and-gradle

前述の通りbook-managerをマルチプロジェクト構成に変更したものです。
マルチプロジェクト構成になっていますが、パッケージ構成やKotlinのプログラム部分などは全く同じです。
Kotlinや各ライブラリのバージョンは、適宜最新のものにアップグレードしています。

オニオンアーキテクチャとは?

オニオンアーキテクチャは2008年にJeffrey Palermo氏のブログで定義された、アーキテクチャです。
プロジェクトをいくつかの階層に分けて役割を定義し、各階層の依存関係を制御することでコード間の関係を疎結合にし、保守性を高めることができます。

特に大規模なシステムや、長期に渡って保守運用での改修などが求められるシステムで有効になります。 階層間の関係性をたまねぎのような層状の丸形で表現されることから、オニオンアーキテクチャと名付けられています。
DDDを実践する際のアーキテクチャの一つとしてもよく挙げられます。

以下の記事なども、参考になると思います。

little-hands.hatenablog.com

Gradleのマルチプロジェクトにするメリット

オニオンアーキテクチャは前述の通り、いくつかの階層に分けて依存関係を制御しています。
例えば以下のようなことです。

  • User Interface層のコードからApplication Service層のコードを呼び出し→○
  • Domain Service層のコードからApplication Service層のコードを呼び出し→×
  • User Interface層のコードからInfrastructure層のコードを呼び出し→×

こういった呼び出しの取り決めを、マルチプロジェクトで階層ごとをサブプロジェクトに分け、それぞれの依存関係を定義することで実装上できなくなるように制御できます。
実際に定義した内容を見ながら詳しく解説していきます。

build.gradle.ktsの構成

書籍のサンプルコードと同様、GradleはKotlin DSLを使用して記述します。 まず、settings.gradleに以下の記述をしています。

pluginManagement {
    repositories {
        maven("https://dl.bintray.com/kotlin/kotlin-eap")

        mavenCentral()

        maven("https://plugins.gradle.org/m2/")
    }
}
rootProject.name = "kotlin-onion-architecture-example-with-springboot-and-gradle"

include(":presentation")
include(":infrastructure")
include(":application")
include(":domain")

下の方で4つのパスをincludeしていますが、これは各ディレクトリをサブプロジェクトとして扱うための設定になります。

そして、build.gradleが以下の内容になっています。

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.5.2"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    id("com.arenagod.gradle.MybatisGenerator") version "1.4"
    id("com.github.ben-manes.versions") version "0.39.0"
    kotlin("jvm") version "1.5.20"
    kotlin("plugin.spring") version "1.5.20"
}

allprojects {
    group = "com.book.manager"
    version = "0.0.1-SNAPSHOT"

    repositories {
        mavenCentral()
    }

    apply(plugin = "kotlin")

    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-reflect")
        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
        testImplementation("org.junit.jupiter:junit-jupiter:5.7.2")
        testImplementation("org.assertj:assertj-core:3.20.2")
        testImplementation("org.mockito:mockito-core:3.11.2")
        testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }

    tasks.withType<KotlinCompile> {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict")
            jvmTarget = "11"
        }
    }
}

configure(listOf(project("presentation"))) {
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")
    apply(plugin = "org.springframework.boot")
    apply(plugin = "io.spring.dependency-management")

    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-web")
        testImplementation("org.springframework.boot:spring-boot-starter-test") {
            exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
        }
        implementation("org.springframework.boot:spring-boot-starter-security")
        implementation("org.springframework.boot:spring-boot-starter-aop")
        implementation("org.springframework.boot:spring-boot-starter-data-redis")
        implementation("org.springframework.session:spring-session-data-redis")
        implementation("redis.clients:jedis")
        implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
        implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.3")
        testImplementation("org.springframework.security:spring-security-test")
        implementation(project(":infrastructure"))
        implementation(project(":application"))
        implementation(project(":domain"))
    }
}

configure(listOf(project("infrastructure"))) {
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")
    apply(plugin = "com.arenagod.gradle.MybatisGenerator")

    dependencies {
        implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0")
        implementation("org.mybatis.dynamic-sql:mybatis-dynamic-sql:1.2.1")
        implementation("mysql:mysql-connector-java:8.0.25")
        mybatisGenerator("org.mybatis.generator:mybatis-generator-core:1.4.0")
        implementation(project(":domain"))
    }

    mybatisGenerator {
        verbose = true
        configFile = "${projectDir}/src/main/resources/generatorConfig.xml"
    }
}

configure(listOf(project("application"))) {
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")

    dependencies {
        implementation("org.springframework.security:spring-security-core:5.5.1")
        implementation("org.springframework:spring-tx:5.3.8")
        implementation(project(":domain"))
    }
}

簡単に言うと、全ての層に追加する設定はallprojectsのブロック、各階層のサブプロジェクトごとの設定はconfigureのブロックに定義しています。
configureに引数でサブプロジェクトのリスト(project("xxxx")で定義しているもの)を渡すことで、そのサブプロジェクトに必要な設定を記述できます。

そして各階層のdependenciesブロックの中で、implementation(project(":xxxx"))とprojectを追加しているところが、その階層から依存する他のサブプロジェクトになります。
ここで追加している階層以外のサブプロジェクトのコードは、参照することができません。

依存関係でルール違反を防げる例

例えばApplication層ではDomain層への依存しか追加していないため、Presentation層やInfrastructure層で定義されているクラスは扱うことができません。
ありそうな例で言うと、Application層で定義しているRequestのクラスをApplication層のServiceクラスに引数で渡そうとするとコンパイルエラーになります。

以下はサンプルコード内にあるAdminBookControllerの例ですが、RegisterBookRequestというリクエストを受け取るクラスから、Domain層で定義されているBookクラスに値を詰め替えてServiceクラスのregister関数に渡しています。

@PostMapping("/register")
fun register(@RequestBody request: RegisterBookRequest) {
    adminBookService.register(
        Book(
            request.id,
            request.title,
            request.author,
            request.releaseDate
        )
    )
}

このregisterという関数はApplication層で以下のように定義されているのですが、

fun register(book: Book) {
    bookRepository.findWithRental(book.id)?.let { throw IllegalArgumentException("既に存在する書籍ID: ${book.id}") }
    bookRepository.register(book)
}

もし「どうせ同じ値が入ってるから、Requestのクラスそのまま渡せばいいだろう」と実装しようとする人がいたとしても、

fun register(book: RegisterBookRequest) {
    bookRepository.findWithRental(book.id)?.let { throw IllegalArgumentException("既に存在する書籍ID: ${book.id}") }
    bookRepository.register(book)
}

と書くとRegisterBookRequestが参照できなくてコンパイルエラーになります。
多人数での開発になると、ルールで縛っているだけではどうしても防ぎきれなくなる場合もあるので、このようにエラーで弾いてくれるのは有用です。
また、レビューなどでこういった点を指摘しなくて良くなり、開発効率の向上にもつながります。

各階層で定義している内容

ここから、build.gradle.ktsの内容を各階層ごとに説明していきます。

※階層ごとのディレクトリにbuild.gradle.ktsのファイルを作成してincludeするという方法もありますが、今回はわかりやすくするために1ファイルにまとめています

allprojects(全体へ影響する設定)

前述の通り、全ての層に追加する必要のある設定は、allprojectsのブロックで記述します。
以下の内容になります。

allprojects {
    group = "com.book.manager"
    version = "0.0.1-SNAPSHOT"

    repositories {
        mavenCentral()
    }

    apply(plugin = "kotlin")

    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-reflect")
        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
        testImplementation("org.junit.jupiter:junit-jupiter:5.7.2")
        testImplementation("org.assertj:assertj-core:3.20.2")
        testImplementation("org.mockito:mockito-core:3.11.2")
        testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }

    tasks.withType<KotlinCompile> {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict")
            jvmTarget = "11"
        }
    }
}

kotlin-reflectkotlin-stdlib-jdk8junit-jupiterとその他テストで使うライブラリなどがあります。
これらは階層関係なく使用するものなので、ここに定義しています。

Presentation層

Presentation層は、Controllerなど主にUIに直結する部分の処理の実装を担います。

内容は以下になります。
ここでの設定は、presentation配下のパッケージでのみ適用されます。

configure(listOf(project("presentation"))) {
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")
    apply(plugin = "org.springframework.boot")
    apply(plugin = "io.spring.dependency-management")

    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-web")
        testImplementation("org.springframework.boot:spring-boot-starter-test") {
            exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
        }
        implementation("org.springframework.boot:spring-boot-starter-security")
        implementation("org.springframework.boot:spring-boot-starter-aop")
        implementation("org.springframework.boot:spring-boot-starter-data-redis")
        implementation("org.springframework.session:spring-session-data-redis")
        implementation("redis.clients:jedis")
        implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
        implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.3")
        testImplementation("org.springframework.security:spring-security-test")
        implementation(project(":infrastructure"))
        implementation(project(":application"))
        implementation(project(":domain"))
    }
}

spring-boot-starter-webはここに追加しています。
main関数の定義されているBookManagerApplicationもpresentation層に配置され、アプリケーションの起動もpresentation:bootRunから行います。

また、Spring SecurityやAOPの依存関係もここに追加していて、認証・認可のハンドラーやIntercepterの処理をこのPresentation層で実装しています。

一番下にあるimplementation(project(":xxxxxx"))の3行が、他の階層のサブプロジェクトへの依存関係になります。
Presentation層からは、Infrastructure層、Application層、Domain層へ依存させています。
オニオンアーキテクチャの構造でいうと横の関係にあるInfrastructure層への依存は必要ないのですが、仕組み上これを入れないと動かせないため定義されています(詳細は後述します)。

Infrastructure層

Infrastructure層には、データベースや外部サービスへの接続など、主にI/Oに関する技術スタックの詳細な実装が含まれています。
内容としては以下になります。

configure(listOf(project("infrastructure"))) {
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")
    apply(plugin = "com.arenagod.gradle.MybatisGenerator")

    dependencies {
        implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0")
        implementation("org.mybatis.dynamic-sql:mybatis-dynamic-sql:1.2.1")
        implementation("mysql:mysql-connector-java:8.0.25")
        mybatisGenerator("org.mybatis.generator:mybatis-generator-core:1.4.0")
        implementation(project(":domain"))
    }

    mybatisGenerator {
        verbose = true
        configFile = "${projectDir}/src/main/resources/generatorConfig.xml"
    }
}

このアプリケーションで言うとデータベースへの接続に関する部分が対象になります。
そのためmybatis-spring-boot-starterなど、ORMであるMyBatisに関連するもの、そしてMySQL Connectorの依存関係をが追加されています。
MyBatis関連のコード生成を行うMyBatis Generatorに関する実装も、ここに記述しています。

これでこれらを使ったデータベースを扱うのに必要な実装は、Infrastructure層に閉じ込められる形になります。

他の階層への依存としては、Repositoryで使用するドメインモデルを含む、Domain層のみとなっています。

Application層

Application層は各機能の仕様に応じた処理(いわゆるユースケース)に応じた処理を実装する階層で、主にServiceクラスの実装が含まれています。
内容としては以下になります。

configure(listOf(project("application"))) {
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")

    dependencies {
        implementation("org.springframework.security:spring-security-core:5.5.1")
        implementation("org.springframework:spring-tx:5.3.8")
        implementation(project(":domain"))
    }
}

認証・認可関連の実装でSpring Securityの一部機能を使うためspring-security-core、そしてトランザクション管理をServiceクラス単位で行っているためspring-txを使っています。
他の階層への依存は、Domain層のみとなります。

Domain層

Domain層は特に設定はありません。
一番内側の階層になるため、他の階層への依存はなく、外部のライブラリやフレームワークに依存するような実装も他の階層が担っているためです。
allprojectsのブロックで設定した、全体に影響する依存関係のみが適用されます。

微妙な点

SpringのDIの仕様上Presentation層からInfrastructure層への依存が必要

Presentation層のところの説明で、仕組み上動かせないため本来必要のないInfrastructure層への依存を追加している旨を書きました。
その理由がこちらになります。

依存関係逆転の法則でDomain層のRepositoryインターフェースに対し、Infrastructure層のRepositoryImplクラスをDIしています。
その関係で、Presentation層からInfrastructure層を参照できるようにしておかないと、DIの対象が見つけられず起動時に以下のようなエラーになってしまいます。

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in com.book.manager.application.service.AuthenticationService required a bean of type 'com.book.manager.domain.repository.UserRepository' that could not be found.


Action:

Consider defining a bean of type 'com.book.manager.domain.repository.UserRepository' in your configuration.

これを入れることで、例えばPresentation層のControllerクラスからInfrastructure層のRepositoryImplクラスを呼ぶということも構造上はできてしまうので(普通はやらないと思いますが)、「依存関係を縛る」という目的から少し外れるのが微妙かなと思っています。

【追記:2021年11月19日】


Kengo TODAさんより以下のご指摘をいただきました。

github.com

Infrastructure層への依存を以下のようにruntimeOnlyで定義することで改善します。

runtimeOnly(project(":infrastructure"))

依存関係として記述する必要はありますが、これにより実行時にだけ読み込み可能となりコンパイルのクラスパスには含まれなくなるため、

例えばPresentation層のControllerクラスからInfrastructure層のRepositoryImplクラスを呼ぶということも構造上はできてしまうので

という問題が解消され実装上の依存関係の縛りを保てるようになりました。
ご指摘いただきありがとうございました。


一部で二重に追加している依存関係がある(Application層のspring-security-coreへの依存など)

Application層に追加しているspring-security-coreは、認証系の処理の具体ロジックを書いたServiceクラスがあり、使用するため追加しています。
しかし、Spring Security関連の依存関係はPresentation層で追加しているspring-boot-starter-securityにも含まれているため、2つの階層でそれぞれ追加していて二重になります。

このサンプルではSpring Securityだけですが、Springは色々な機能を有していて各種starterの中にもいくつかのモジュールが含まれていることもあるので、こういったことは他にも起こる可能性はありえます。
フルスタックなフレームワークゆえに、全体へ影響を与える可能性が高いため、こういった分割した構成とは少し相性が悪いかもと思いました。

いっそSpring関連の各種プラグインと複数階層で使えるstarterをallprojectsに移動しても良さそう

追加する依存関係の影響を局所化する意味ではあまり望ましくないですが、spring-boot-starter-webや前述のspring-boot-starter-securityなど複数の階層で渡って使えそうなstarterは、allprojectsブロックに入れてしまうのもありかなと考えています。

allprojects {
    group = "com.book.manager"
    version = "0.0.1-SNAPSHOT"

    repositories {
        mavenCentral()
    }

    apply(plugin = "kotlin")
    // Spring関連のプラグインを移動
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")
    apply(plugin = "org.springframework.boot")
    apply(plugin = "io.spring.dependency-management")

    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-reflect")
        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
        // spring-boot-starter-web、spring-boot-starter-securityを移動
        implementation("org.springframework.boot:spring-boot-starter-web")
        implementation("org.springframework.boot:spring-boot-starter-security")
        testImplementation("org.springframework.boot:spring-boot-starter-test") {
            exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
        }
        testImplementation("org.junit.jupiter:junit-jupiter:5.7.2")
        testImplementation("org.assertj:assertj-core:3.20.2")
        testImplementation("org.mockito:mockito-core:3.11.2")
        testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }

    tasks.withType<KotlinCompile> {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict")
            jvmTarget = "11"
        }
    }
}

こうすることでApplication層での設定も、プラグインの定義やspring-security-coreの追加を消して、少しスッキリします。

configure(listOf(project("application"))) {
    dependencies {
        implementation("org.springframework:spring-tx")
        implementation(project(":domain"))
    }
}

そもそもstarterを使っている時点で少なからず(不要なものも含めた)余分なモジュールを取り込んではいる可能性はあるので、影響範囲は広がりますがこのくらいは許容してもいいかなと思っています。

あとはconfigureにはlistを渡しているので、階層ごとのものとは別に以下のように特定のいくつかのサブプロジェクトに対して設定を記述することもできるので、全体ではなく必要なプロジェクトだけに追加することも一応できます。

configure(listOf(project("presentation"), project("application"))) {
    // ・・・    
}

が、各サブプロジェクトに対する設定の記述が分散して見づらいのと、サブプロジェクトの組み合わせがいくつも生まれてくるとものすごく分かりづらくなるので、あまりオススメしません。

まとめ

Gradleのマルチプロジェクトを使用してオニオンアーキテクチャ(またはクリーンアーキテクチャ等)を実現した際のメリット・デメリットをまとめると、

メリット

  • アーキテクチャ上の制限に反するコードを実装段階で防ぐことができる
  • レビューの段階で指摘する項目が減り、品質や開発効率の向上につながる

デメリット

  • Springの仕組み上から、不要な依存関係(Presentation層→Infrastructure層)の追加が必要になる
  • 一部で二重に依存関係を追加するライブラリ等が出てくる可能性がある
  • Gradleの構成は少し複雑になる

といったところですね。
デメリットの部分は気持ち悪さはありますが、ある程度許容できるものでもあるかなと思っています。

少なくとも通常の1つのプロジェクトとして扱う構成に比べれば、縛れる部分が増えるメリットは間違いなくあるので、選択肢としては充分にありです。

【宣伝】サーバーサイドKotlinの書籍を発売しています!

冒頭にも紹介していますが、私の執筆した書籍「Kotlin サーバーサイドプログラミング実践開発」が発売中です!

gihyo.jp

タイトル通り実践での開発にも持っていける内容になっているので、サーバーサイドKotlinを始めてみたい方はぜひお手にとっていただければと思います。