チュートリアルどおりにController・Service・Repositoryは書けるようになったのに、機能が増えてくると「このクラスはどこに置けばいいんだろう」と急に手が止まる。そんな経験ありますよね。
パッケージ構成には絶対の正解がありません。ただ、方針の選び方と崩さないコツを知っておくと、後で困りにくくなります。この記事ではレイヤー型とフィーチャー型という2大方針を比較して、自分のプロジェクトに合う選び方と、選んだあとに構成を崩さないための仕組みづくりまでを見ていきましょう。
なぜパッケージ構成で悩むのか
チュートリアルの段階ではクラスが数個しかないので、どこに置いても困りません。ところが機能が10個20個と増えていくと、配置の基準がないままパッケージがどんどん膨らんでいきます。
やっかいなのは、置き場所の迷いがそのまま命名のブレや依存関係の乱れ、テストの書きにくさに波及していくことです。最初に「どう分けるか」の軸を決めておくと、こうした劣化をかなり防げます。
この記事は実装手順そのものではなく、あくまで「置き場所をどう決めるか」にフォーカスします。CRUDの書き方そのものは Spring BootでREST APIのCRUDを実装する にまとめてあるので、そちらを参照してください。
レイヤー型パッケージ構成とは
まず多くの人が最初に通るのがレイヤー型です。controller service repository domain のように、技術的な役割でパッケージを横に切ります。
com.example.app
├── controller
│ ├── UserController.java
│ └── OrderController.java
├── service
│ ├── UserService.java
│ └── OrderService.java
├── repository
│ ├── UserRepository.java
│ └── OrderRepository.java
└── domain
├── User.java
└── Order.java
層ごとの責務がはっきりしていて、学習段階でも直感的に理解しやすい構造です。「Controllerはここ」「Repositoryはここ」という規約を層単位で適用しやすいのも利点で、機能が1〜2個の小さなアプリならこれで十分まわります。
レイヤー型が抱える肥大化の問題
問題が出てくるのは機能が増えてきたときです。service パッケージを開いたら20個も30個もServiceが並んでいて、UserまわりとOrderまわりがどこからどこまでなのか名前を頼りに目で追うしかない、という状態になります。
もう一つ地味につらいのが横断変更です。「ユーザー機能にひとつ項目を足す」だけの変更でも、controller service repository domain を行ったり来たりすることになります。
そして機能間の依存がパッケージ構造から見えません。OrderServiceがこっそりUserServiceを呼んでいても、フラットに並んだ構造だと結合が進んでいることに気づきにくいのです。
フィーチャー(機能)型パッケージ構成とは
そこで出てくるのがフィーチャー型です。user order payment のように業務機能の単位でパッケージを分け、その中に各層を置きます。縦に切る、と言うとイメージしやすいでしょうか。
com.example.app
├── user
│ ├── UserController.java
│ ├── UserService.java
│ ├── UserRepository.java
│ └── User.java
├── order
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ └── Order.java
└── config
└── WebConfig.java
この形だと、ユーザー機能の変更は基本的に user パッケージの中で完結します。変更が局所化されて凝集度が高くなるので、機能の追加・削除も、チームでの担当分けもパッケージ単位でやりやすくなります。
一方で、機能ごとのクラスが少ないうちは1パッケージにファイルが数個しかなく、ちょっと冗長に感じる初期コストはあります。
フィーチャー型の内部で層をどう表現するか
機能の中身がさらに育ってきたら、機能パッケージの内側にサブパッケージを切って層を表現します。
com.example.app
├── user
│ ├── web
│ ├── service
│ └── repository
├── order
│ ├── web
│ ├── service
│ └── repository
├── common
└── config
機能で縦に切りつつ、その中で層を保つ折衷形ですね。config common shared のような横断的な要素は、特定の機能に属さないので独立したパッケージにまとめます。ただし common は何でも放り込むゴミ箱になりがちなので、本当に複数機能で共有するものだけに絞るのがコツです。
どちらを選ぶか:規模と成長見込みで判断する
選び方はシンプルで、規模と成長見込みで決めます。
機能数が少なく、寿命も短い小さなツールなら、まずレイヤー型で十分なことが多いです。無理に機能型にしてもパッケージが空っぽに近くなるだけですから。
逆に機能が継続的に増えていく見込みがある、あるいは個人開発から中規模チームへ移っていくなら、フィーチャー型のほうが破綻しにくいです。担当者ごとに触る範囲が分かれるので、コンフリクトも減ります。
迷ったときは「将来の成長見込み」を重視して、フィーチャー型寄りに倒しておくのがおすすめです。あとからレイヤー型を機能型へ移すときは、いきなり全部やろうとせず、まず1機能だけを user パッケージに切り出してみて、感触を確かめてから広げると安全です。
依存方向を保つ原則
構成を選んだら、次に大事なのが依存方向です。ここが崩れると、どんな構成にしても結局ぐちゃぐちゃになります。
基本は上位から下位への一方向です。web → service → repository → domain の向きだけを許して、下位レイヤーが上位を参照しないようにします。とくにドメイン(エンティティやビジネスルール)は、できるだけフレームワークに依存させないでおくと長持ちします。
フィーチャー型では、これに加えて機能間の直接依存を避けることが重要です。order から user の内部クラスを直接触るのではなく、公開する入口クラスやインターフェースを通して呼ぶ。そうやって結合点を明示的にしておくと、あとで機能を切り離すのが楽になります。
なおDIの書き方しだいでも依存の見通しは変わります。コンストラクタ注入を基本にする話は Spring BootのDI:フィールド・セッター・コンストラクタ注入の比較 を参照してください。
ArchUnitで依存ルールを自動テストする
依存方向は「気をつけよう」だけだと、レビューをすり抜けて少しずつ崩れます。そこで ArchUnit を使って、依存ルールをテストとして固定してしまいましょう。
まずレイヤー依存のルールです。Controllerが直接Repositoryを触っていないか、といった違反を検出できます。
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(packages = "com.example.app")
class LayerDependencyTest {
@ArchTest
static final ArchRule layerRule = layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Web").definedBy("..web..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
}
もう一つ入れておきたいのが循環依存の検出です。パッケージ間でサイクルができると依存方向が事実上壊れるので、これを禁止します。
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(packages = "com.example.app")
class CycleTest {
@ArchTest
static final ArchRule noCycles = slices()
.matching("com.example.app.(*)..")
.should().beFreeOfCycles();
}
このテストをCIに組み込んでおけば、構成を崩す変更がレビュー前に止まります。人間が毎回チェックするより確実で、しかも疲れません。
フィーチャー型からSpring Modulithへの発展
フィーチャー型でパッケージを機能ごとに整えておくと、その境界はそのまま「モジュールの境界」の候補になります。ここがSpring Modulithへの自然な入口です。
Spring Modulithは、機能パッケージをモジュールとして扱い、機能間の依存を検証したり、モジュール同士をイベントで疎結合に保ったりするのを支援してくれます。つまり、フィーチャー型で整えた構成を一段引き上げる発展経路、というイメージです。
モジュール境界の宣言やイベントの永続化といった具体的な手順は Spring Modulithでモジュラーモノリスを実現する方法 にまとめているのでそちらに譲りますが、今フィーチャー型で機能をきれいに分けておくほど、将来Modulithへ移るときのコストが下がる、という点だけ覚えておいてください。
まとめ:迷ったときの指針
パッケージ構成は最初から完璧を目指すものではなく、規模に合わせて育てていくものです。指針をシンプルにまとめておきます。
- 小規模で短命ならレイヤー型、成長見込みがあるならフィーチャー型を基本にする
- どちらを選んでも、依存方向の原則とArchUnitによる自動検証が長期的な鍵になる
- 構成は固定せず、規模の変化に応じて段階的に移行していい
例外処理のような横断的関心事をどこに置くか悩んだら Spring BootのREST API例外ハンドリング も参考になります。まずは自分のプロジェクトの「今の規模」と「これからの伸び」を見て、一方に倒すところから始めてみましょう。