Spring Boot のパッケージ構成は、役割で分ける「層別」と、業務のまとまりで分ける「機能別」の2つが基本で、どちらの場合もメインクラスをルートパッケージに置くのが出発点です。 Spring Boot 自体は特定の構成を求めていませんが、公式リファレンスには守るとよい約束ごとが書かれています。

この記事では、コピーして使えるディレクトリツリー3種と全ファイルのコード、「何をどこに置くか」の対応表、規模から選ぶ判断表、層別から機能別へ移す手順、依存の向きを調べる Java スクリプトをまとめます。

確認環境:Spring Boot 4.1.1 の公式リファレンスで確認(2026年10月7日)。Java 単体で再現できる部分は Java 21(OpenJDK 21.0.10)で実行。コンポーネントスキャンの範囲は Spring Framework 4.3.30、SQL は PostgreSQL 16、ファイルの移動は git で実行しています。

パッケージ構成の早見表

構成 分け方 合う規模の目安 よいところ
層別 controller・service・repository など役割で分ける 機能が少ない、学習用 役割が一目で分かる
機能別 order・member など機能で分ける 機能が増えてきた 1つの変更が1フォルダで済む
機能別+層 機能の中をさらに役割で分ける 1機能のファイルが多い 機能の独立と役割の見やすさを両立

どの構成でも共通する約束は次の3つです。

約束 内容
パッケージを必ず付ける package 宣言のない「デフォルトパッケージ」は使わない
メインクラスはルートに @SpringBootApplication のクラスを一番上のパッケージに置く
依存は一方向に Controller → Service → Repository の向きにそろえる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Bootパッケージ構成|層別と機能別のツリーと選び方の基準」

公式リファレンスが示している約束ごと

公式リファレンス「Structuring Your Code」(4.1.1)の要点は次のとおりです。

  • Spring Boot は特定のコード配置を必須にしていないが、役に立つ約束ごとがある
  • デフォルトパッケージを避ける。 @ComponentScan・@ConfigurationPropertiesScan・@EntityScan・@SpringBootApplication を使うアプリでは特に問題になりやすい。com.example.project のように、ドメイン名を逆にしたパッケージ名を付ける
  • メインクラスを、他のクラスより上のルートパッケージに置く。 @SpringBootApplication を付けたクラスのパッケージが、探索の起点(search package)になる

公式が例として載せているのは、次の機能別の形です。

com
 +- example
     +- myapplication
         +- MyApplication.java
         |
         +- customer
         |   +- Customer.java
         |   +- CustomerController.java
         |   +- CustomerService.java
         |   +- CustomerRepository.java
         |
         +- order
             +- Order.java
             +- OrderController.java
             +- OrderService.java
             +- OrderRepository.java

また同じページで、ドメインを軸に構成したい場合の選択肢として Spring Modulith が紹介されています。

メインクラスの位置でスキャンの範囲が変わる

「起点のパッケージとその下だけが探される」ことを、@ComponentScan を付けたクラスの位置を変えて確かめます。@SpringBootApplication を使わない場合は @EnableAutoConfiguration と @ComponentScan を別々に付けられる、と公式にあるとおり、スキャンの起点を決めているのはこの仕組みです。ここでは Spring Framework 4.3.30 の spring-context だけで動かしています。

src
|-- ScanDemo.java
`-- com
    `-- example
        |-- common
        |   `-- IdGenerator.java
        `-- shop
            |-- RootConfig.java
            |-- app
            |   |-- StartupRunner.java
            |   `-- SubConfig.java
            |-- member
            |   `-- MemberService.java
            `-- order
                `-- OrderService.java

8 directories, 7 files

RootConfig(ルートに置いた場合)と SubConfig(一段深い app に置いた場合)です。

package com.example.shop;

import org.springframework.context.annotation.ComponentScan;

// ルートパッケージに置いた場合(メインクラスをここに置くのと同じ位置)
@ComponentScan
public class RootConfig {
}
package com.example.shop.app;

import org.springframework.context.annotation.ComponentScan;

// 一段深い app パッケージに置いた場合
@ComponentScan
public class SubConfig {
}

OrderService・MemberService は @Service、StartupRunner・IdGenerator は @Component を付けただけの空のクラスです。それぞれを起点にして、登録された Bean を表示します。

import java.util.Arrays;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ScanDemo {
    public static void main(String[] args) {
        show(com.example.shop.RootConfig.class);
        show(com.example.shop.app.SubConfig.class);
    }

    static void show(Class<?> config) {
        try (AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(config)) {
            System.out.println("起点: " + config.getName());
            Arrays.stream(ctx.getBeanDefinitionNames())
                    .filter(n -> !n.startsWith("org.springframework"))
                    .map(n -> "  " + n + " -> " + ctx.getBean(n).getClass().getName())
                    .sorted()
                    .forEach(System.out::println);
        }
    }
}

実行結果

起点: com.example.shop.RootConfig
  memberService -> com.example.shop.member.MemberService
  orderService -> com.example.shop.order.OrderService
  rootConfig -> com.example.shop.RootConfig
  startupRunner -> com.example.shop.app.StartupRunner
起点: com.example.shop.app.SubConfig
  startupRunner -> com.example.shop.app.StartupRunner
  subConfig -> com.example.shop.app.SubConfig
起点の位置 見つかったもの 見つからなかったもの
com.example.shop(ルート) order・member・app の3つ com.example.common の IdGenerator
com.example.shop.app(一段下) app の1つだけ order・member・common

メインクラスを app のような下のパッケージに置くと、横に並ぶ order や member は探されません。 ルートパッケージに置けば、追加の設定なしで全体が対象になります。com.example.common のようにルートの外にあるものは、どちらの場合も対象外です。

ツリー1|層別の構成

役割ごとにパッケージを分ける形です。注文(order)と会員(member)の2機能を持つ小さな API を例にします。

shop
|-- pom.xml
`-- src
    |-- main
    |   |-- java
    |   |   `-- com
    |   |       `-- example
    |   |           `-- shop
    |   |               |-- ShopApplication.java
    |   |               |-- config
    |   |               |   `-- ClockConfig.java
    |   |               |-- controller
    |   |               |   |-- MemberController.java
    |   |               |   `-- OrderController.java
    |   |               |-- dto
    |   |               |   |-- MemberResponse.java
    |   |               |   |-- OrderRequest.java
    |   |               |   `-- OrderResponse.java
    |   |               |-- entity
    |   |               |   |-- Member.java
    |   |               |   `-- Order.java
    |   |               |-- exception
    |   |               |   |-- GlobalExceptionHandler.java
    |   |               |   `-- NotFoundException.java
    |   |               |-- mapper
    |   |               |   `-- OrderMapper.java
    |   |               |-- repository
    |   |               |   |-- MemberRepository.java
    |   |               |   `-- OrderRepository.java
    |   |               `-- service
    |   |                   |-- MemberService.java
    |   |                   `-- OrderService.java
    |   `-- resources
    |       |-- application.yml
    |       |-- db
    |       |   `-- migration
    |       |       `-- V1__create_tables.sql
    |       |-- static
    |       `-- templates
    `-- test
        `-- java
            `-- com
                `-- example
                    `-- shop
                        `-- mapper
                            `-- OrderMapperTest.java

26 directories, 20 files

全ファイルのコード

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>shop</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Flyway は、インメモリ型・ファイル型以外の DB では Flyway 側の DB 別モジュールも合わせて追加します(公式 How-to「Database Initialization」)。

ShopApplication.java(ルートパッケージに置く)

package com.example.shop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

entity/Order.java

package com.example.shop.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "member_id", nullable = false)
    private Long memberId;

    @Column(name = "item_name", nullable = false)
    private String itemName;

    @Column(nullable = false)
    private int quantity;

    protected Order() {
        // JPA 用
    }

    public Order(Long memberId, String itemName, int quantity) {
        this.memberId = memberId;
        this.itemName = itemName;
        this.quantity = quantity;
    }

    public Long getId() {
        return id;
    }

    public Long getMemberId() {
        return memberId;
    }

    public String getItemName() {
        return itemName;
    }

    public int getQuantity() {
        return quantity;
    }
}

entity/Member.java

package com.example.shop.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "members")
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    protected Member() {
        // JPA 用
    }

    public Member(String name) {
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

repository/OrderRepository.java

package com.example.shop.repository;

import com.example.shop.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {
}

repository/MemberRepository.java

package com.example.shop.repository;

import com.example.shop.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

dto/OrderRequest.java

package com.example.shop.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record OrderRequest(
        @NotNull Long memberId,
        @NotBlank String itemName,
        @Min(1) int quantity) {
}

dto/OrderResponse.java

package com.example.shop.dto;

public record OrderResponse(Long id, Long memberId, String itemName, int quantity) {
}

dto/MemberResponse.java

package com.example.shop.dto;

public record MemberResponse(Long id, String name) {
}

mapper/OrderMapper.java

package com.example.shop.mapper;

import com.example.shop.dto.OrderRequest;
import com.example.shop.dto.OrderResponse;
import com.example.shop.entity.Order;

public final class OrderMapper {

    private OrderMapper() {
    }

    public static Order toEntity(OrderRequest request) {
        return new Order(request.memberId(), request.itemName(), request.quantity());
    }

    public static OrderResponse toResponse(Order order) {
        return new OrderResponse(order.getId(), order.getMemberId(), order.getItemName(), order.getQuantity());
    }
}

service/OrderService.java

package com.example.shop.service;

import com.example.shop.dto.OrderRequest;
import com.example.shop.dto.OrderResponse;
import com.example.shop.entity.Order;
import com.example.shop.exception.NotFoundException;
import com.example.shop.mapper.OrderMapper;
import com.example.shop.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final MemberService memberService;

    public OrderService(OrderRepository orderRepository, MemberService memberService) {
        this.orderRepository = orderRepository;
        this.memberService = memberService;
    }

    @Transactional
    public OrderResponse create(OrderRequest request) {
        memberService.find(request.memberId()); // 会員がいなければ NotFoundException
        Order saved = orderRepository.save(OrderMapper.toEntity(request));
        return OrderMapper.toResponse(saved);
    }

    @Transactional(readOnly = true)
    public OrderResponse find(Long id) {
        return orderRepository.findById(id)
                .map(OrderMapper::toResponse)
                .orElseThrow(() -> new NotFoundException("注文が見つかりません: id=" + id));
    }
}

service/MemberService.java

package com.example.shop.service;

import com.example.shop.dto.MemberResponse;
import com.example.shop.exception.NotFoundException;
import com.example.shop.repository.MemberRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional(readOnly = true)
    public MemberResponse find(Long id) {
        return memberRepository.findById(id)
                .map(member -> new MemberResponse(member.getId(), member.getName()))
                .orElseThrow(() -> new NotFoundException("会員が見つかりません: id=" + id));
    }
}

controller/OrderController.java

package com.example.shop.controller;

import com.example.shop.dto.OrderRequest;
import com.example.shop.dto.OrderResponse;
import com.example.shop.service.OrderService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderResponse create(@Valid @RequestBody OrderRequest request) {
        return orderService.create(request);
    }

    @GetMapping("/{id}")
    public OrderResponse find(@PathVariable Long id) {
        return orderService.find(id);
    }
}

controller/MemberController.java

package com.example.shop.controller;

import com.example.shop.dto.MemberResponse;
import com.example.shop.service.MemberService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/members")
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/{id}")
    public MemberResponse find(@PathVariable Long id) {
        return memberService.find(id);
    }
}

exception/NotFoundException.java

package com.example.shop.exception;

public class NotFoundException extends RuntimeException {

    public NotFoundException(String message) {
        super(message);
    }
}

exception/GlobalExceptionHandler.java

package com.example.shop.exception;

import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Map<String, String> handleNotFound(NotFoundException e) {
        return Map.of("message", e.getMessage());
    }
}

config/ClockConfig.java

package com.example.shop.config;

import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ClockConfig {

    @Bean
    public Clock clock() {
        return Clock.systemDefaultZone();
    }
}

src/main/resources/application.yml

spring:
  application:
    name: shop

logging:
  level:
    root: info
    com.example.shop: debug

src/main/resources/db/migration/V1__create_tables.sql

CREATE TABLE members (
    id   BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE orders (
    id        BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    member_id BIGINT       NOT NULL REFERENCES members (id),
    item_name VARCHAR(200) NOT NULL,
    quantity  INTEGER      NOT NULL CHECK (quantity >= 1)
);

この SQL を PostgreSQL 16 で流した結果です。

CREATE TABLE
CREATE TABLE
          List of relations
 Schema |  Name   | Type  |  Owner   
--------+---------+-------+----------
 public | members | table | postgres
 public | orders  | table | postgres
(2 rows)

src/test/java/com/example/shop/mapper/OrderMapperTest.java

package com.example.shop.mapper;

import static org.junit.jupiter.api.Assertions.assertEquals;

import com.example.shop.dto.OrderRequest;
import com.example.shop.entity.Order;
import org.junit.jupiter.api.Test;

class OrderMapperTest {

    @Test
    void toEntityCopiesAllFields() {
        Order order = OrderMapper.toEntity(new OrderRequest(1L, "ノート", 3));

        assertEquals(1L, order.getMemberId());
        assertEquals("ノート", order.getItemName());
        assertEquals(3, order.getQuantity());
    }
}

テストは src/test/java の下に、対象クラスと同じパッケージで置きます。

ツリー2|機能別の構成

同じアプリを、機能(order・member)ごとにまとめた形です。公式リファレンスの例と同じ分け方で、機能をまたいで使うものは common に寄せています。

shop
|-- pom.xml
`-- src
    |-- main
    |   |-- java
    |   |   `-- com
    |   |       `-- example
    |   |           `-- shop
    |   |               |-- ShopApplication.java
    |   |               |-- common
    |   |               |   |-- config
    |   |               |   |   `-- ClockConfig.java
    |   |               |   `-- exception
    |   |               |       |-- GlobalExceptionHandler.java
    |   |               |       `-- NotFoundException.java
    |   |               |-- member
    |   |               |   |-- Member.java
    |   |               |   |-- MemberController.java
    |   |               |   |-- MemberRepository.java
    |   |               |   |-- MemberResponse.java
    |   |               |   `-- MemberService.java
    |   |               `-- order
    |   |                   |-- Order.java
    |   |                   |-- OrderController.java
    |   |                   |-- OrderMapper.java
    |   |                   |-- OrderRepository.java
    |   |                   |-- OrderRequest.java
    |   |                   |-- OrderResponse.java
    |   |                   `-- OrderService.java
    |   `-- resources
    |       |-- application.yml
    |       |-- db
    |       |   `-- migration
    |       |       `-- V1__create_tables.sql
    |       |-- static
    |       `-- templates
    `-- test
        `-- java
            `-- com
                `-- example
                    `-- shop
                        `-- order
                            `-- OrderMapperTest.java

23 directories, 20 files

コードの中身は層別と同じで、変わるのは package 文と import 文だけです。同じパッケージになったクラスは import が要らなくなるので、ファイルは短くなります。

order/OrderService.java(機能別に移した後)

package com.example.shop.order;

import com.example.shop.common.exception.NotFoundException;
import com.example.shop.member.MemberService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final MemberService memberService;

    public OrderService(OrderRepository orderRepository, MemberService memberService) {
        this.orderRepository = orderRepository;
        this.memberService = memberService;
    }

    @Transactional
    public OrderResponse create(OrderRequest request) {
        memberService.find(request.memberId()); // 会員がいなければ NotFoundException
        Order saved = orderRepository.save(OrderMapper.toEntity(request));
        return OrderMapper.toResponse(saved);
    }

    @Transactional(readOnly = true)
    public OrderResponse find(Long id) {
        return orderRepository.findById(id)
                .map(OrderMapper::toResponse)
                .orElseThrow(() -> new NotFoundException("注文が見つかりません: id=" + id));
    }
}

order/OrderController.java(機能別に移した後)

package com.example.shop.order;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderResponse create(@Valid @RequestBody OrderRequest request) {
        return orderService.create(request);
    }

    @GetMapping("/{id}")
    public OrderResponse find(@PathVariable Long id) {
        return orderService.find(id);
    }
}

OrderService が import している自分のアプリのクラスは、common の例外と member の MemberService の2つだけになりました。機能の外への依存が import の行としてそのまま見えるのが、機能別のよいところです。

ツリー3|機能別の中を層で分ける構成

1つの機能にファイルが増えてきたら、機能の中を役割で分けます。パッケージ名はフォルダのとおりで、たとえば OrderService は com.example.shop.order.service になります。

src/main/java/com/example/shop
|-- ShopApplication.java
|-- common
|   |-- config
|   |   `-- ClockConfig.java
|   `-- exception
|       |-- GlobalExceptionHandler.java
|       `-- NotFoundException.java
|-- member
|   |-- controller
|   |   `-- MemberController.java
|   |-- dto
|   |   `-- MemberResponse.java
|   |-- entity
|   |   `-- Member.java
|   |-- repository
|   |   `-- MemberRepository.java
|   `-- service
|       `-- MemberService.java
`-- order
    |-- controller
    |   `-- OrderController.java
    |-- dto
    |   |-- OrderRequest.java
    |   `-- OrderResponse.java
    |-- entity
    |   `-- Order.java
    |-- mapper
    |   `-- OrderMapper.java
    |-- repository
    |   `-- OrderRepository.java
    `-- service
        `-- OrderService.java

17 directories, 16 files

機能の中だけで使うクラスは、公開範囲を狭めておくと他の機能から呼ばれにくくなります。機能別(ツリー2)のように1パッケージにまとめている間は、public を外してパッケージ内だけに見せる方法も使えます。

置くものと置く場所の対応表

置くもの 層別での場所 機能別での場所 補足
メインクラス ルート直下 ルート直下 @SpringBootApplication
Controller controller 各機能 リクエストの受付と応答
Service service 各機能 業務の処理とトランザクション
Repository repository 各機能 DB アクセス
Entity entity 各機能 テーブルに対応するクラス
DTO dto 各機能 リクエスト/レスポンスの形
Mapper mapper 各機能 Entity と DTO の変換
設定クラス config common/config @Configuration
例外・例外ハンドラ exception common/exception 機能専用の例外は各機能へ
定数 constant 使う機能、共通は common 使う側の近くに置く
設定ファイル src/main/resources 同じ application.yml
画面テンプレート resources/templates 同じ 既定で自動的に読み込まれる
静的ファイル resources/static 同じ CSS・JavaScript・画像
マイグレーションSQL resources/db/migration 同じ V<版>__<名前>.sql
テスト src/test/java の同じパッケージ 同じ 対象クラスと同じ場所

resources 側の場所は、公式リファレンスの記述に沿っています。テンプレートは src/main/resources/templates から自動で読み込まれ、静的ファイルはクラスパスの /static(または /public・/resources・/META-INF/resources)から配信されます。Flyway のマイグレーションは既定で classpath:db/migration です。なお、jar として配布するアプリでは src/main/webapp は使わないよう注意書きがあります。

どの構成を選ぶかの判断表

数字は目安です。チームで見やすいと感じる方を選んで差し支えありません。

見るところ 層別 機能別 機能別+層
機能の数 1〜3 4以上 4以上
1機能のファイル数 問わない 10未満 10以上
開発する人数 1〜3人 3人以上 複数チーム
向いている進め方 学習、試作 機能ごとに担当を分ける 機能を部品として育てる
こう感じたら 次の一手
service フォルダが長くなって探しにくい 機能別へ移す
1つの修正で4〜5個のフォルダを行き来する 機能別へ移す
機能フォルダの中が20ファイルを超えた 機能の中を層で分ける
common が大きくなってきた 使っている機能へ戻せるものを戻す

小さく始めるなら層別、機能が増えたら機能別へ、という順で進めると移行の手間も小さく済みます。

依存の向きをそろえる

flowchart LR
    C["Controller<br>受付と応答"] --> S["Service<br>業務の処理"]
    S --> R["Repository<br>DBアクセス"]
    R --> E["Entity"]
    S --> E
    C -.-> D["DTO"]
    S -.-> D

矢印は「呼ぶ側 → 呼ばれる側」です(点線は DTO の受け渡し)。Controller から Repository への矢印は引いていません。Controller は Service だけを知り、Repository は Service から使う形にそろえると、トランザクションの範囲や業務のルールが Service に集まります。

ControllerがRepositoryを直接呼んでいる例

package com.example.shop.controller;

import com.example.shop.dto.OrderRequest;
import com.example.shop.dto.OrderResponse;
import com.example.shop.entity.Order;
import com.example.shop.repository.OrderRepository;
import com.example.shop.service.OrderService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;
    private final OrderRepository orderRepository; // Controller が Repository を直接持っている

    public OrderController(OrderService orderService, OrderRepository orderRepository) {
        this.orderService = orderService;
        this.orderRepository = orderRepository;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderResponse create(@Valid @RequestBody OrderRequest request) {
        return orderService.create(request);
    }

    @GetMapping("/{id}")
    public OrderResponse find(@PathVariable Long id) {
        // 検索と変換を Controller の中で行っている
        Order order = orderRepository.findById(id).orElseThrow();
        return new OrderResponse(order.getId(), order.getMemberId(), order.getItemName(), order.getQuantity());
    }
}

この形でも動きますが、検索と変換のコードが Controller に入るため、同じ処理を別の画面やバッチから使いたいときに重複しやすくなります。見つからなかったときの扱いも、Service 側の NotFoundException とは別になっています。

直した例

package com.example.shop.controller;

import com.example.shop.dto.OrderRequest;
import com.example.shop.dto.OrderResponse;
import com.example.shop.service.OrderService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderResponse create(@Valid @RequestBody OrderRequest request) {
        return orderService.create(request);
    }

    @GetMapping("/{id}")
    public OrderResponse find(@PathVariable Long id) {
        return orderService.find(id);
    }
}

Controller が持つのは OrderService だけになり、検索・変換・見つからないときの例外は Service に集まりました。

依存の向きを調べるスクリプト

クラス名の末尾(Controller・Service・Repository)で層を判定し、import 文と同じパッケージ内の参照を調べて、向きに沿わないものを表示します。Java 21 の単一ファイル実行でそのまま動きます。

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

/**
 * Controller -> Service -> Repository の向きに沿わない参照を探す。
 * 使い方: java LayerCheck.java src/main/java
 */
public class LayerCheck {

    // 層の順番。数字が小さいほど外側(リクエストに近い側)
    static final Map<String, Integer> LAYERS = new LinkedHashMap<>();
    static {
        LAYERS.put("Controller", 1);
        LAYERS.put("Service", 2);
        LAYERS.put("Repository", 3);
    }

    record Source(String pkg, String name, String text) {
        String fqcn() {
            return pkg + "." + name;
        }
    }

    public static void main(String[] args) throws IOException {
        Path root = Path.of(args.length > 0 ? args[0] : "src/main/java");
        List<Source> sources = new ArrayList<>();
        try (Stream<Path> paths = Files.walk(root)) {
            for (Path p : paths.filter(f -> f.toString().endsWith(".java")).sorted().toList()) {
                String text = Files.readString(p);
                Matcher m = Pattern.compile("(?m)^package\\s+([\\w.]+);").matcher(text);
                String pkg = m.find() ? m.group(1) : "";
                String name = p.getFileName().toString().replace(".java", "");
                sources.add(new Source(pkg, name, text));
            }
        }

        Map<String, Integer> counts = new TreeMap<>();
        List<String> findings = new ArrayList<>();
        for (Source from : sources) {
            String fromLayer = layerOf(from.name());
            if (fromLayer == null) {
                continue;
            }
            for (Source to : sources) {
                String toLayer = layerOf(to.name());
                if (toLayer == null || from == to || !refers(from, to)) {
                    continue;
                }
                counts.merge(fromLayer + " -> " + toLayer, 1, Integer::sum);
                int diff = LAYERS.get(toLayer) - LAYERS.get(fromLayer);
                if (diff < 0) {
                    findings.add(from.name() + " -> " + to.name() + "(逆向き: " + fromLayer + " が " + toLayer + " を参照)");
                } else if (diff > 1) {
                    findings.add(from.name() + " -> " + to.name() + "(層を飛ばしている: " + fromLayer + " が " + toLayer + " を参照)");
                }
            }
        }

        System.out.println("調べたファイル: " + sources.size());
        System.out.println("層から層への参照:");
        counts.forEach((k, v) -> System.out.println("  " + k + " : " + v));
        System.out.println("見直し候補: " + findings.size() + " 件");
        findings.forEach(f -> System.out.println("  " + f));
        System.exit(findings.isEmpty() ? 0 : 1);
    }

    static String layerOf(String className) {
        for (String suffix : LAYERS.keySet()) {
            if (className.endsWith(suffix)) {
                return suffix;
            }
        }
        return null;
    }

    // import 文があるか、同じパッケージでクラス名が本文に出てくれば「参照している」とみなす
    static boolean refers(Source from, Source to) {
        if (from.text().contains("import " + to.fqcn() + ";")) {
            return true;
        }
        return from.pkg().equals(to.pkg())
                && Pattern.compile("\\b" + Pattern.quote(to.name()) + "\\b").matcher(from.text()).find();
    }
}

実行結果(Controller が Repository を直接呼んでいる状態)

$ java LayerCheck.java src/main/java
調べたファイル: 16
層から層への参照:
  Controller -> Repository : 1
  Controller -> Service : 2
  Service -> Repository : 2
  Service -> Service : 1
見直し候補: 1 件
  OrderController -> OrderRepository(層を飛ばしている: Controller が Repository を参照)
$ echo $?
1

実行結果(直した後)

$ java LayerCheck.java src/main/java
調べたファイル: 16
層から層への参照:
  Controller -> Service : 2
  Service -> Repository : 2
  Service -> Service : 1
見直し候補: 0 件
$ echo $?
0

見直し候補があると終了コードが 1 になるので、ビルドの前に走らせる使い方もできます。Service -> Service : 1 は、OrderService が MemberService を呼んでいる分です。機能別の構成に移した後も、同じ結果になりました。

層別から機能別へ移す手順

層別のツリーを機能別のツリーへ、履歴を残したまま移します。IDE の「クラスの移動」機能を使うと package 文と import 文まで自動で直るので、ふだんはそちらが手軽です。ここでは、何が変わるのかが分かるようにシェルで行います。

手順 すること
1 作業前の状態をコミットしておく
2 機能ごとのパッケージを作り、git mv でファイルを移す
3 package 文と import 文を新しい場所に合わせる
4 コンパイルして、足りない import を補う
5 依存の向きとテストを確かめてコミットする
#!/usr/bin/env bash
# 層別 -> 機能別 への移動(リポジトリのルートで実行)
set -euo pipefail
BASE=src/main/java/com/example/shop
TEST=src/test/java/com/example/shop

# 1. 機能ごとのパッケージを作り、ファイルを git mv で移す
mkdir -p "$BASE/order" "$BASE/member" "$BASE/common" "$TEST/order"
for layer in controller service repository entity dto mapper; do
  for f in "$BASE/$layer"/Order*.java;  do [ -e "$f" ] && git mv "$f" "$BASE/order/";  done
  for f in "$BASE/$layer"/Member*.java; do [ -e "$f" ] && git mv "$f" "$BASE/member/"; done
done
git mv "$BASE/exception" "$BASE/common/exception"
git mv "$BASE/config"    "$BASE/common/config"
git mv "$TEST/mapper/OrderMapperTest.java" "$TEST/order/"

# 2. package 文と import 文を新しい場所に合わせる
LAYERS='controller|service|repository|entity|dto|mapper'
find src -name '*.java' -print0 | xargs -0 sed -i -E \
  -e "s/com\.example\.shop\.($LAYERS)\.(Order[A-Za-z]*)/com.example.shop.order.\2/g" \
  -e "s/com\.example\.shop\.($LAYERS)\.(Member[A-Za-z]*)/com.example.shop.member.\2/g" \
  -e "s/com\.example\.shop\.(exception|config)\b/com.example.shop.common.\1/g"
sed -i -E 's/^package .*;/package com.example.shop.order;/'  "$BASE"/order/*.java "$TEST"/order/*.java
sed -i -E 's/^package .*;/package com.example.shop.member;/' "$BASE"/member/*.java

# 3. 同じパッケージになって不要になった import を消す
sed -i -E '/^import com\.example\.shop\.order\.[A-Za-z]+;$/d'  "$BASE"/order/*.java "$TEST"/order/*.java
sed -i -E '/^import com\.example\.shop\.member\.[A-Za-z]+;$/d' "$BASE"/member/*.java

# 4. 空になったフォルダを片付ける
find src -type d -empty -delete

実行後の git status --short(抜粋)です。R は名前の変更として記録されたことを表します。

RM src/main/java/com/example/shop/config/ClockConfig.java -> src/main/java/com/example/shop/common/config/ClockConfig.java
RM src/main/java/com/example/shop/entity/Member.java -> src/main/java/com/example/shop/member/Member.java
RM src/main/java/com/example/shop/controller/MemberController.java -> src/main/java/com/example/shop/member/MemberController.java
…(省略)…
RM src/main/java/com/example/shop/service/OrderService.java -> src/main/java/com/example/shop/order/OrderService.java
RM src/test/java/com/example/shop/mapper/OrderMapperTest.java -> src/test/java/com/example/shop/order/OrderMapperTest.java

つまずきやすい点|同じパッケージだったクラスのimport

この時点でコンパイルすると、次のエラーが出ました。

src/main/java/com/example/shop/order/OrderService.java:11: error: cannot find symbol
    private final MemberService memberService;
                  ^
  symbol:   class MemberService
  location: class OrderService

層別のときは OrderService と MemberService が同じ service パッケージにあり、import 文がありませんでした。機能別に分けると別のパッケージになるので、これまで書かずに済んでいた import を足す必要があります。

import com.example.shop.member.MemberService;

この1行を足すとコンパイルが通り、依存の向きのチェックも 0 件でした。コミット後、移動したファイルの履歴は --follow でたどれます。

$ git log --follow --oneline -- src/main/java/com/example/shop/order/OrderService.java
b4a3344 機能別の構成へ移動
2dc3d42 層別の構成

全体では 16 ファイルが移動し、差分は「19 行の追加、37 行の削除」でした。削除が多いのは、同じパッケージになって不要になった import が消えたためです。

よくある質問

Q. 層別と機能別のどちらが正しいのですか? A. どちらも使われている構成で、公式リファレンスも特定の配置を必須にはしていません。公式の例は機能別ですが、機能が少ないうちは層別のほうが見通しやすい場面も多くあります。規模に合わせて選び、必要になったら移せば十分です。

Q. メインクラスをルート以外に置きたいときはどうしますか? A. スキャンの起点がそのパッケージになるので、横や上にあるクラスは自動では見つかりません。ルートパッケージに置くのが最も手間のかからない方法です。

Q. DTO と Entity は分けたほうがよいですか? A. 分けておくと、テーブルの列を変えても API の形に影響が出にくくなります。この記事の例では OrderRequest/OrderResponse を DTO、Order を Entity として分け、変換を OrderMapper に集めています。

Q. 機能をまたぐ呼び出しはどう書きますか? A. 例では OrderService が MemberService を呼んでいます。相手の機能の Service を呼び、Repository には直接触れないと決めておくと、機能の境目がはっきりします。

まとめ

  • メインクラスはルートパッケージに置く。 そのパッケージと下の階層がスキャンの対象になる
  • 層別は役割で、機能別は業務のまとまりで分ける。 公式リファレンスの例は機能別
  • 機能が増えたら機能別へ、1機能が大きくなったら中を層で分ける
  • 依存は Controller → Service → Repository の一方向にそろえる
  • 置き場所は対応表のとおり。 設定は resources、テンプレートは templates、SQL は db/migration
  • 移行は git mv と package/import の書き換え。 同じパッケージだったクラスの import を足すのを忘れずに
  • 向きのチェックはスクリプトにしておくと、構成が保たれているかをいつでも確かめられる

参考・出典

いずれも 2026年10月7日に確認しました。

  • Spring Boot リファレンス 4.1.1「Structuring Your Code」: https://docs.spring.io/spring-boot/reference/using/structuring-your-code.html
  • Spring Boot リファレンス 4.1.1「Servlet Web Applications」: https://docs.spring.io/spring-boot/reference/web/servlet.html
  • Spring Boot How-to「Database Initialization」: https://docs.spring.io/spring-boot/how-to/data-initialization.html
  • Spring Boot 4.0 Migration Guide: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide
  • Spring Boot プロジェクトページ: https://spring.io/projects/spring-boot

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Bootパッケージ構成|層別と機能別のツリーと選び方の基準」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?