Engineering
Glossvation Engineering
Web・モバイル

Spring Boot プロジェクト構成 — 10 人規模で崩れないパッケージ設計

Controller / Service / Repository の責務境界、ドメイン層の切り方、テストの並べ方まで。Spring Boot API 開発で「後から直せなくなるコード」を防ぐためのパッケージ構成の指針。

Glossvation ·

Spring Boot パッケージ構成

なぜ 「パッケージ構成」 の話をするのか

Spring Boot で API を書き始めると、最初の 1 ヶ月は爆速で進みます。3 ヶ月後、 人が増えて 「Service にビジネスロジック以外もまぎれ始める」、 「どこに何を書けばいいかメンバーで揃わない」、「テストが書きにくい箇所が出てくる」 — だいたいこの頃にパッケージ構成の歪みが現れます。

パッケージ構成はドキュメントの代わり になります。構造を見ただけで「どこに何を書けばよいか」が自明になっている構成は、 新メンバーのオンボーディングにも、レビューの速度にも直接効きます。

本稿では、Glossvation で複数の業務システムに適用して崩れにくかった構成を紹介します。 正解ではなく、たたき台 として読んでください。

大枠: 4 層 + 共通モジュール

com.example.api/
├── App.java                  # @SpringBootApplication
├── controller/               # 入口 (HTTP)
│   ├── v1/
│   └── v2/
├── domain/                   # ビジネスの中核
│   ├── model/                # 不変の値オブジェクト・DTO
│   ├── entity/               # 永続化 (DB) に対応するクラス
│   ├── repository/           # 永続化の抽象 (interface + impl)
│   └── service/              # ユースケース
├── exception/                # 独自例外 + @ControllerAdvice
├── security/
│   ├── filter/               # 認証フィルタ
│   └── role/                 # ロール定義
├── common/
│   ├── config/               # @Configuration
│   ├── property/             # @ConfigurationProperties
│   ├── filter/               # アプリ全体に効く Filter
│   └── util/                 # ステートレスな計算
└── task/                     # @Scheduled ジョブ

テストも同じ構造で:

src/test/java/com/example/api/
├── AppTests.java
├── controller/
├── domain/
│   ├── service/
│   └── repository/
└── common/

各層の責務

controller/ — HTTP の変換層

  • @RestController を置く
  • ビジネスロジックを書かない (バリデーションの橋渡しと Service 呼び出しに専念)- API バージョンは v1/ v2/ で物理的に分離 (@RequestMapping だけでなくパッケージで)
  • レスポンス DTO は controller パッケージに置く (domain と混ぜない)
@RestController
@RequestMapping("/v1/orders")
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> create(@Validated @RequestBody OrderCreateRequest req) {
        var order = orderService.create(req.toCommand());
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

domain/service/ — ユースケース層

  • 1 ユースケース = 1 メソッド (createOrder, cancelOrder…)
  • @Transactional は ここに付ける (controller や repository ではなく)
  • 外部通信 (API, DB) は repository / infra に委ねる
  • テスト対象として最も重要 → 単体テスト率を高く保つ

domain/repository/ — 永続化の抽象

  • interface は domain 配下に置く (ドメインの言葉で定義)
  • JPA / MyBatis / R2DBC などの実装はここに
  • 外部 API クライアントもここに置く (DB だけが永続化ではない)

domain/model vs domain/entity

  • model/ = ドメイン内で引き回す値 (不変、record 推奨、DB 非依存)
  • entity/ = JPA / 永続化フレームワーク固有のクラス (@Entity 等)

このふたつを混ぜると、永続化の都合 (@ManyToOne, lazy loading) がドメインに染み出して疎結合が崩れます。 変換関数 (UserEntity → User) を repository 境界で挟むのが鍵。

common/ — アプリ横断

  • config/ — Bean 定義 (@Configuration)
  • property/ — @ConfigurationProperties でまとめた設定クラス
  • util/ — static メソッドのみ、状態を持たない

common を 「何でも置き場」 にしない。分類できないものは、別の層に属する責務を見逃しているサインです。

exception/ — エラー変換

  • 独自例外 (OrderNotFoundException 等) を定義
  • @RestControllerAdvice で HTTP ステータスに変換する箇所を集約
  • ドメイン例外 → HTTP の変換ロジックは 1 ヶ所に閉じ込める
@RestControllerAdvice
public class ApiExceptionHandler {
    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handle(OrderNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of("ORDER_NOT_FOUND", e.getMessage()));
    }
}

DI の流れ (依存の向き)

Controller → Service → Repository (interface)
                  ↓               ↑
               Domain  ←    実装 (JPA/MyBatis)

依存は 「内向き」 に流す (= Controller 側がドメインに依存し、ドメインは Controller を知らない)。 これを守ると、Web 層やフレームワーク差し替えが容易になります (いわゆる Hexagonal / Clean Architecture の考え方)。

落とし穴

1. Service がファット化する

1 メソッドが 300 行超えたら黄信号。 ユースケースの粒度を見直し、ドメインサービスか、専用 Command/Query クラスに切り出します。

2. DTO を domain に置く

DTO は Controller 入出力のための データ転送用。 ドメインモデルと混ぜると、API 契約の変更がドメインに波及します。 Controller パッケージ配下に *Request, *Response として置くのが基本。

3. 共通処理を Controller/Service に書く

ログ、認可、監査などは AOP か Filter / Interceptor に外す。 Controller/Service に書くと重複して保守不能になります。

4. application.yml にシークレットを書く

本番の認証情報は 環境変数 + ${...} で参照、 または AWS Secrets Manager / Vault / Spring Cloud Config 経由で注入。 application-prod.yml にベタ書きしない。

5. バージョンアップ時の @Deprecated ゾンビ

Spring Boot 2 → 3 でパッケージ名が javax.* → jakarta.* に大変化しました。 古い @Deprecated を放置せずに 移行の都度削除 します。

近代化アップデート (2017 → 2024+)

当時現代の推奨
Spring Boot 1.x / 2.xSpring Boot 3.x (Java 17+, Jakarta EE 9+)
Lombok @Datarecord (不変 DTO) + lombok は補助に
手書き Builder@Builder (Lombok) or record + toBuilder()
WebSecurityConfigurerAdapter廃止。SecurityFilterChain Bean で定義
JUnit 4 + @RunWith(SpringRunner.class)JUnit 5 + @SpringBootTest (単体テストは @MockBean ではなく @MockitoBean or Mockito 直接)
Executor の手動設定Spring Boot 3.1+ の仮想スレッド spring.threads.virtual.enabled=true を検討

まとめ: パッケージ構成は 「こう決めた」 と文書化するだけでチームの速度が変わります。本稿の構造は 1 つの叩き台ですが、責務の境界を明文化し、新規コードを書くときの迷いを消す こと自体が重要です。

関連記事