本記事は、2026/5/30開催の「JJUG CCC 2026 Spring」の登壇内容に関連した技術記事シリーズです。
AIエージェント(Copilot / Claude Code / Cursor)の普及により、Java開発の前提は大きく変わりました。
本シリーズでは「AI時代における設計の変化」をテーマに、実務視点で整理していきます。
【シリーズ一覧】
- AI時代にJava設計はどう変わったのか
- カッペリーニコードとは何か
- Java17/21は設計をどう変えたか
- AIは設計できるのか
- AI時代の設計ガードレール
はじめに
AIエージェントがコードを書く時代になった。
GitHub Copilot、Claude Code、Cursorを使えば、DTOやRepositoryといった定型コードは驚くほど高速に生成できる。
しかし現場では、こんな問題が静かに進行している。
・Service層が消えてControllerがRepositoryを直接呼ぶ
・API単位でDTOが爆発的に増殖する
・モジュール境界を無視したimportが広がる
・Utilityクラスがドメインロジックを吸収し始める
コードはきれいなのに、構造が壊れていく。
これはAIの問題ではなく、ガードレールがない開発環境の問題だ。
本記事では、AI時代のSpring Boot開発に必要な設計ガードレールを、実務で使える具体策として整理する。
なぜガードレールが必要か
AIは「局所最適」が得意で「全体設計」が苦手
AIエージェントは目の前のタスクを解くことに長けている。
「このエンドポイントを実装して」と指示すれば、動くコードを返す。
問題は、AIが既存の設計文脈を理解しないまま実装することだ。
次のコードを見てほしい。
@RestController
@RequiredArgsConstructor
public class OrderController {
private final UserRepository userRepository; // ← Controller が Repository を直接参照
private final OrderRepository orderRepository;
@PostMapping("/orders")
public OrderResponse create(@RequestBody CreateOrderRequest request) {
User user = userRepository.findById(request.userId())
.orElseThrow(() -> new NotFoundException("User not found"));
Order order = new Order(user, request.items());
orderRepository.save(order);
return new OrderResponse(order.getId());
}
}
AIが生成したこのコードは動く。しかし設計は壊れている。
-
Controller → Repositoryの直接依存(レイヤ違反) - トランザクション境界がController側に滲み出る
- ビジネスロジックがControllerに混入している
これが1ファイルなら修正できる。しかしAI生成を繰り返すと、この「パターン」がコードベース全体に広がる。
ガードレールがない開発で起きること
| 症状 | 原因 |
|---|---|
| Service層の消失 | ControllerがRepositoryを直接呼ぶ |
| DTO爆発 | AI がAPI単位でRecordを生成し続ける |
| Utilityの増殖 | 共通処理をUtilに逃がすAIの癖 |
| モジュール境界崩壊 | 他featureのRepositoryを直接importする |
| トランザクション散在 |
@Transactional をController/Repositoryに書く |
これらはいずれもAIがコードを書くと発生しやすいパターンだ。
防ぐには、構造を事前に固定するしかない。
設計ルール
パッケージ構造ルール
レイヤー型ではなくfeature型を採用する。
レイヤー型は一見整理されているが、機能が増えるとfeature間の依存が制御しにくくなる。
# NG: レイヤー型(機能が増えると依存が複雑化する)
com.example
├── controller
│ ├── UserController.java
│ └── OrderController.java
├── service
│ ├── UserService.java
│ └── OrderService.java
└── repository
├── UserRepository.java
└── OrderRepository.java
# OK: feature型(モジュール境界が明確)
com.example
├── user
│ ├── controller
│ │ └── UserController.java
│ ├── service
│ │ └── UserService.java
│ ├── domain
│ │ └── User.java
│ ├── repository
│ │ └── UserRepository.java
│ └── dto
│ ├── CreateUserRequest.java
│ └── UserResponse.java
└── order
├── controller
│ └── OrderController.java
├── service
│ └── OrderService.java
├── domain
│ └── Order.java
├── repository
│ └── OrderRepository.java
└── dto
├── CreateOrderRequest.java
└── OrderResponse.java
この構造のポイントはfeature間のRepository直接参照を禁止できることだ。
# OK: featureをまたぐ場合はServiceを経由する
orderService → userService.findById()
# NG: featureをまたいでRepositoryを直接参照する
orderService → userRepository.findById()
レイヤー責務ルール
各レイヤーの責務を明文化し、チーム全員が参照できる場所に置く。
Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@PostMapping
public UserResponse create(@RequestBody @Validated CreateUserRequest request) {
return userService.create(request);
}
}
- 依存を許可するのはServiceのみ
- DTOのみ扱い、Entityを返さない
- ビジネスロジックは一切書かない
-
@Transactionalは書かない
Service
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateEmailException(request.email());
}
User user = User.create(request.name(), request.email());
userRepository.save(user);
return UserResponse.from(user);
}
}
-
トランザクション境界はServiceが持つ(クラスレベルに
@Transactional) - ビジネスロジック・不変条件チェックはServiceに集約する
- 依存を許可するのは同一feature内のRepositoryまたは他featureのService
Repository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}
- DBアクセスのみ
- ビジネスロジックを書かない
- クエリが複雑な場合は
@Queryを使い、ロジックは書かない
Domain(Entity)
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// ファクトリメソッドで生成制約を表現
public static User create(String name, String email) {
User user = new User();
user.name = Objects.requireNonNull(name, "name must not be null");
user.email = Objects.requireNonNull(email, "email must not be null");
return user;
}
}
- 不変条件はEntityに閉じ込める
- Controller層への依存は絶対に持たない
-
@Setterは原則使わない(状態変更はメソッドで表現する)
命名規約
命名が曖昧だとAIが誤ったパターンで補完する。明文化することでAIへのヒントにもなる。
| 種別 | 命名パターン | 例 |
|---|---|---|
| Controller | {Feature}Controller |
UserController |
| Service | {Feature}Service |
UserService |
| Repository | {Feature}Repository |
UserRepository |
| Entity | {FeatureName} |
User, Order
|
| リクエストDTO | {動詞}{Feature}Request |
CreateUserRequest, UpdateUserRequest
|
| レスポンスDTO | {Feature}Response |
UserResponse |
| 詳細レスポンス | {Feature}DetailResponse |
UserDetailResponse |
| 例外 | {原因}Exception |
DuplicateEmailException |
禁止命名
XxxUtil / XxxUtils → ドメインに寄せる
XxxHelper → 責務が不明確
XxxManager → Serviceに統合する
XxxCommon → featureごとに分ける
実装例:ArchUnitで自動検証する
設計ルールは文書化するだけでは守られない。ArchUnitでテストとして実装する。
依存関係
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
レイヤー違反の検出
@AnalyzeClasses(packages = "com.example")
public class LayerArchitectureTest {
@ArchTest
static final ArchRule controller_should_not_depend_on_repository =
noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat()
.resideInAPackage("..repository..")
.because("ControllerはRepositoryを直接参照してはいけません。Serviceを経由してください。");
@ArchTest
static final ArchRule controller_should_not_depend_on_domain =
noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat()
.resideInAPackage("..domain..")
.because("ControllerはEntityを直接扱ってはいけません。DTOを使用してください。");
@ArchTest
static final ArchRule repository_should_not_depend_on_service =
noClasses()
.that().resideInAPackage("..repository..")
.should().dependOnClassesThat()
.resideInAPackage("..service..")
.because("Repositoryは上位レイヤーに依存してはいけません。");
}
Transactionalの配置検証
@AnalyzeClasses(packages = "com.example")
public class TransactionalArchitectureTest {
@ArchTest
static final ArchRule transactional_should_be_on_service_only =
noClasses()
.that().resideOutsideOfPackage("..service..")
.should().beAnnotatedWith(Transactional.class)
.because("@TransactionalはService層にのみ使用してください。");
}
モジュール境界(feature間のRepository直接参照禁止)
@AnalyzeClasses(packages = "com.example")
public class ModuleBoundaryTest {
@ArchTest
static final ArchRule order_should_not_access_user_repository =
noClasses()
.that().resideInAPackage("com.example.order..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.user.repository..")
.because("featureをまたぐRepository参照は禁止です。Serviceを経由してください。");
}
Utilityクラスの禁止
@AnalyzeClasses(packages = "com.example")
public class NamingArchitectureTest {
@ArchTest
static final ArchRule no_utility_classes =
noClasses()
.should().haveSimpleNameEndingWith("Utils")
.orShould().haveSimpleNameEndingWith("Util")
.orShould().haveSimpleNameEndingWith("Helper")
.because("Utilityクラスは禁止です。ロジックはドメインまたはServiceに配置してください。");
}
運用方法
AIへのプロンプト制御
AIエージェントに対して、プロジェクトのルールを明示的に伝えることで生成品質を上げられる。
CLAUDE.md / .cursorrules / Copilot Instructions に記述する
# Architecture Rules
## Package Structure
- Use feature-based package structure: `com.example.{feature}.{layer}`
- Layers: controller / service / domain / repository / dto
## Layer Dependencies
- Controller → Service only (never Repository or domain directly)
- Service → Repository (same feature only)
- Cross-feature access: always via Service, never via Repository
## Naming Conventions
- Request DTO: `{Verb}{Feature}Request` (e.g. CreateUserRequest)
- Response DTO: `{Feature}Response` (e.g. UserResponse)
- Never create Util / Helper / Manager / Common classes
## Transaction
- @Transactional is allowed only in Service layer (class level)
- Never put @Transactional in Controller or Repository
## Entity Rules
- Always use factory methods instead of public constructors
- Never use @Setter on Entity fields
- Never return Entity from Controller (always convert to DTO)
このファイルを置くことで、AIエージェントがプロジェクトの設計ルールを文脈として参照しながらコードを生成するようになる。
CIパイプラインへの組み込み
ArchUnitテストはユニットテストとして実行できるため、CIに組み込むだけで設計違反を自動検出できる。
# GitHub Actions の例
- name: Run Architecture Tests
run: ./mvnw test -Dtest=*ArchitectureTest,*ModuleBoundaryTest,*NamingArchitectureTest
CIが落ちたら設計違反、というルールにすることで、レビュー前に機械的に検出できる。
PRレビューの観点シフト
AIがコードを書く時代のPRレビューは、構文ではなく構造を見ることに集中すべきだ。
| 従来のレビュー観点 | AI時代のレビュー観点 |
|---|---|
| 変数名が適切か | レイヤー依存が正しいか |
| ロジックのバグがないか | モジュール境界を越えていないか |
| コードが読みやすいか |
@Transactional の位置が正しいか |
| テストが書かれているか | EntityがControllerに漏れていないか |
AIが書いたコードのバグはAIが直せる。しかし構造の問題はレビュアーが指摘しなければ残り続ける。
AIに任せる領域・任せない領域
AI生成に向いている領域と、人間が設計すべき領域を明確にしておく。
AIに任せる(構造が決まっていれば高品質に生成できる)
- DTOのRecord定義
- JPA Repositoryのクエリメソッド
- ControllerのCRUD実装
-
@ControllerAdviceの例外ハンドラー - JUnit / Mockitoを使ったテストコード
人間が設計する(AIには任せない)
- feature分割の境界設計
- Aggregate境界とEntityの不変条件
- トランザクション境界の設計
- 例外設計とエラーレスポンスの体系
- パッケージ構造とモジュール依存の方針
まとめ
AIはコードを書く能力において、定型的な実装では人間を大きく上回り始めている。
しかし構造を設計する主体は人間だ、という事実は変わっていない。
本記事で紹介した設計ガードレールを整理すると次のようになる。
| ガードレール | 手段 |
|---|---|
| パッケージ構造の固定 | feature型構造を標準化する |
| レイヤー依存の制約 | ArchUnitでテスト化してCIに組み込む |
| 命名規約の明文化 | チームドキュメント + CLAUDE.mdに記述 |
| AIプロンプトの制御 |
.cursorrules / CLAUDE.md にルールを書く |
| レビュー観点のシフト | 構文ではなく構造を見るレビューへ |
AIと共存する開発において、アーキテクトに求められるのはコードを書く力ではなく構造を守る力だ。
AIは実装を担当する。人間は構造を設計する。
この役割分担を仕組みとして整備することが、AI時代の設計ガードレールの本質だ。
シリーズ一覧
- AI時代にJava設計はどう変わったのか
- カッペリーニコードとは何か
- Java17/21は設計をどう変えたか
- AIは設計できるのか
- AI時代の設計ガードレール