Java開発でControllerにロジックを書いてはいけない理由とは?Serviceクラスの役割を徹底解説
新人
「先輩、JavaでWebアプリを作っているんですが、Controller(コントローラー)という場所に全てのプログラムを書いても動きますよね?なぜわざわざService(サービス)クラスを作る必要があるんですか?」
先輩
「確かに動くけれど、それをやると後で修正が地獄のように大変になるんだ。いわゆる『Fat Controller(太ったコントローラー)』という問題だね。」
新人
「地獄……。それは避けたいです。具体的にどうしてControllerにビジネスロジックを書いてはいけないのか、教えてください!」
先輩
「OK!今回はControllerの正しい役割と、Serviceクラスを導入するメリットについて詳しく解説していくよ。」
1. なぜControllerにビジネスロジックを書いてはいけないのか?
JavaのWeb開発、特にSpring Bootなどを使っていると、Controller(コントローラー)というクラスを必ず作成します。このControllerは、例えるなら「お店の受付窓口」のような存在です。
窓口の仕事は、お客さん(ユーザー)から注文(リクエスト)を受け取り、それを適切な担当部署に回し、最後に結果をお客さんに返すことです。もし、この窓口の担当者が「商品の調理」や「在庫の管理」、「売上の計算」まで全てその場で一人で行っていたらどうなるでしょうか?
アプリの「本質的な計算や判断」のことです。例えば、「会員ランクに応じて割引率を変える」「在庫が足りない場合にエラーを出す」「銀行口座からお金を引き落とす」といった、そのシステム独自のルールのことを指します。
Controllerにこれらのビジネスロジックを詰め込んでしまうと、以下のような弊害が発生します。
- 役割が多すぎる: 窓口業務(通信制御)と調理業務(計算)を同時にこなすため、コードが複雑になります。
- 使い回しができない: 同じ計算処理を別の画面でも使いたい時、Controllerに書いてあると同じコードを何度もコピー&ペーストしなければなりません。
- テストが困難: Controllerは「ブラウザからの通信」に依存しているため、純粋に「計算が正しいか」だけを確認するテストが非常にやりづらくなります。
プログラミングの世界では、一つのクラスに色々な役割を持たせすぎることは「悪い習慣」とされています。これを防ぐために、役割を分割する必要があるのです。
2. Serviceクラスを導入する最大の目的:関心の分離(SoC)
そこで登場するのがService(サービス)クラスです。Serviceクラスを導入する最大の目的は、関心の分離(Separation of Concerns, SoC)を実現することにあります。
「関心の分離」とは、難しい言葉に聞こえますが、要するに「自分の仕事だけに集中しよう」という考え方です。Webアプリケーションの構造を整理すると、大きく以下の3つの層に分かれます。
Controller層
ユーザーの入力(URLやフォーム)を受け取り、どの画面を表示するかを制御する「交通整理役」。
Service層
計算、データの加工、条件分岐などの「業務ルール」を実際に実行する「職人役」。
Repository層
データベース(棚)からデータを取ってきたり、保存したりする「倉庫番」。
この構成にすることで、Controllerは「Serviceさん、この注文をお願いします!」と頼むだけで済みます。実際の計算方法を知らなくても、Serviceから返ってきた結果を表示するだけでよくなるのです。
ここで、具体的にControllerとServiceを分けたコードの例を見てみましょう。まずは、商品を注文する際のシンプルなロジックを想定します。
【悪い例:Controllerに全部書いてしまった場合】
@RestController
public class OrderController {
// Controllerの中で直接計算やチェックをしてしまっている(Fat Controller)
@PostMapping("/order")
public String placeOrder(int itemId, int quantity) {
// 1. 在庫チェック(本来はServiceの仕事)
if (quantity > 100) {
return "一度に100個以上の注文はできません。";
}
// 2. 金額計算(本来はServiceの仕事)
int price = 500 * quantity;
// 3. データベース保存(本来はRepositoryの仕事だが、直接書かれがち)
System.out.println("アイテム" + itemId + "を" + quantity + "個保存しました。合計:" + price + "円");
return "注文が完了しました。";
}
}
【良い例:Serviceクラスを活用した場合】
まず、ビジネスロジックを担当するServiceクラスを作成します。
@Service
public class OrderService {
// ビジネスロジック(計算やルール)はここに集約
public int calculateTotalPrice(int quantity) {
if (quantity > 100) {
throw new IllegalArgumentException("大量注文制限エラー");
}
return 500 * quantity;
}
public void saveOrder(int itemId, int quantity, int total) {
// データベース保存の処理を呼び出す
System.out.println("注文を記録しました: " + itemId + ", " + total + "円");
}
}
次に、ControllerはServiceを呼び出すだけにします。
@RestController
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/order")
public String placeOrder(int itemId, int quantity) {
try {
// Controllerは「何をすべきか」をServiceに命令するだけ
int total = orderService.calculateTotalPrice(quantity);
orderService.saveOrder(itemId, quantity, total);
return "注文が完了しました。合計金額は" + total + "円です。";
} catch (IllegalArgumentException e) {
// エラーの場合のメッセージ表示もControllerの仕事
return "エラーが発生しました:" + e.getMessage();
}
}
}
このように分けることで、Controllerのコードが非常にスッキリし、何を行っているかが一目でわかるようになります。また、別の「スマホアプリ用API」を作る際にも、同じOrderServiceを再利用できるため、開発効率が劇的に向上します。
3. コードの肥大化を防ぐ!Fat Controllerが引き起こす3つの問題
Controllerにロジックを書きすぎた状態を、プログラミング用語で「Fat Controller(太ったコントローラー)」と呼びます。これは開発現場で最も忌み嫌われる状態の一つです。なぜなら、一度Fat Controllerになってしまうと、プロジェクトのメンテナンス性が著しく低下するからです。
具体的にどのような問題が起こるのか、3つのポイントで解説します。
修正時の影響範囲が特定できない
Fat Controllerは、数千行に及ぶコードが1つのファイルに記述されることも珍しくありません。例えば「消費税率が変わった」という単純な変更を行いたいだけなのに、膨大なコードの中から該当箇所を探し出し、他の処理を壊さないように慎重に修正しなければなりません。 「どこを直せばいいか分からない」「直したら別の場所でバグが出た」という現象(デグレード)が頻発します。
重複コードの温床になる
同じようなロジックが必要になった時、Controllerにロジックが書かれていると、他のControllerからその処理を呼び出すのが難しくなります。その結果、多くの開発者は「似たようなコードをコピーして貼り付ける」という選択をしてしまいます。 コードの重複は悪です。もし仕様変更があった場合、コピーした箇所すべてを修正して回らなければならず、修正漏れが確実に発生します。
チーム開発のボトルネックになる
複数のエンジニアで開発している場合、1つのControllerファイルに全ての機能が詰まっていると、同じファイルを同時に編集する機会が増えます。これにより、Gitなどのバージョン管理システムで「コンフリクト(衝突)」が頻繁に発生し、作業効率が大幅にダウンします。 Serviceクラスとして機能を切り出しておけば、Aさんは「注文サービス」、Bさんは「配送サービス」といった具合に、ファイルを分けて並行作業を進めることが可能になります。
初心者が意識すべき「三層アーキテクチャ」
JavaのWebアプリ開発を学ぶ際は、まずこの「Controller」「Service」「Repository」の3つを意識して、それぞれのフォルダ(パッケージ)を作るところから始めましょう。たとえ数行の簡単な処理であっても、「これはビジネスルールかな?」と考え、Serviceに書く習慣をつけることが脱・初心者の第一歩です。
プログラムの実行結果を確認してみましょう。上記のService構成を動かした場合、正常系と異常系で以下のような結果が得られます。
【実行結果:正常な注文の場合】
注文を記録しました: 101, 5000円
出力:注文が完了しました。合計金額は5000円です。
【実行結果:数量が100を超えた場合】
出力:エラーが発生しました:大量注文制限エラー
このように、ロジック(エラーチェックや計算)がServiceに隠蔽されているおかげで、Controllerは「結果がどうなったか」だけを気にすれば良くなっています。この構造こそが、大規模なシステムを安全に、そして速く作り上げるための秘訣なのです。
4. Service層の役割とは?ビジネスロジックとDB操作の橋渡し
前章までで、Controller(コントローラー)に全ての処理を詰め込む「Fat Controller」の危険性と、役割を分担する重要性について学びました。では、具体的にService(サービス)層がどのような役割を担っているのか、その核心に迫っていきましょう。
Service層の最も重要な役割は、一言で言えば「ビジネスロジック(業務ルール)の実行」と「データ操作のコントロール」です。しかし、初心者のうちは「Repository(レポジトリ)があるなら、Serviceはいらないのでは?」と疑問に思うかもしれません。ここで、それぞれの役割の違いを整理してみましょう。
- Repository: データベースに対する「一行の追加」「検索」といった単純な読み書きのみを行う。
- Service: 「Aテーブルからデータを取得し、計算加工した結果をBテーブルに保存する」といった、複数のデータ操作を組み合わせて一つの「機能」として完成させる。
つまり、Service層はControllerからの依頼を解釈し、Repository(倉庫番)を指揮して目的を達成する「現場監督」のような存在なのです。例えば、銀行の振込処理を考えてみましょう。振込という一つのアクションに対して、システム内部では「振込元の残高を減らす」「振込先の残高を増やす」「履歴を保存する」という複数のステップが必要です。これらを一つのまとまり(業務単位)として管理するのがService層の仕事です。
また、Service層を設けることで、プログラムが「技術的な詳細(HTTPリクエストやSQL)」から解放され、「何をしたいか(ビジネスの目的)」に集中できるようになります。これにより、将来的にデータベースの種類が変わったり、Web以外のインターフェース(バッチ処理など)を追加したりする際にも、Service層のロジックをそのまま流用できるという強力なメリットが生まれます。
具体的な実装例として、ユーザー登録時のロジックを見てみましょう。単に保存するだけでなく、「メールアドレスの重複チェック」や「初期ポイントの付与」といったルールが含まれます。
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
/**
* 新規ユーザー登録処理
* 1. 重複チェック
* 2. パスワードのハッシュ化(擬似処理)
* 3. データの保存
*/
public void registerUser(String email, String password) {
// 1. ビジネスルール:同じメールアドレスは登録できない
if (userRepository.existsByEmail(email)) {
throw new RuntimeException("既に登録されているメールアドレスです。");
}
// 2. ビジネスルール:パスワードを加工する(本来は暗号化など)
String securePassword = "HASHED_" + password;
// 3. Repository(倉庫番)に指示を出して保存
User newUser = new User(email, securePassword);
userRepository.save(newUser);
System.out.println("サービス層:ユーザーをDBに保存しました。");
}
}
このように、Service層があることで「何がビジネス上のルールなのか」が明確になり、プログラムの可読性が飛躍的に向上します。
5. 複数メソッドから使い回す!ロジックの共通化と再利用性の向上
Serviceクラスを導入するもう一つの大きな恩恵は、「ロジックの再利用性」です。開発が進むにつれて、「あの時書いたあの計算、こっちの画面でも使いたいな」という場面が必ず出てきます。その際、Controllerにロジックが書いてあると、同じコードをコピー&ペーストすることになり、負債が蓄積していきます。
例えば、ECサイトにおいて「会員ランクに応じたポイント還元率の計算」が必要だとします。このロジックは、「注文画面」だけでなく、「商品詳細画面(予定ポイント表示)」や「マイページ(ポイントシミュレーション)」など、様々な場所で使用される可能性があります。
こうした共通のロジックをServiceに集約しておくことで、以下のようなメリットを享受できます。
修正が一箇所で済む
還元率が「1%から2%」に変更になっても、Serviceの一箇所を直すだけでシステム全体に反映されます。
テストの信頼性向上
共通化されたServiceを一度テストしておけば、それを利用する全ての箇所で品質が担保されます。
具体的な共通化のコード例を見てみましょう。ポイント計算という独立した機能をServiceとして切り出します。
@Service
public class PointCalculatorService {
/**
* 購入金額と会員ランクから付与ポイントを計算する
* 共通ロジックとして定義
*/
public int calculatePoints(int amount, String memberRank) {
double rate = 0.01; // 基本1%
if ("GOLD".equals(memberRank)) {
rate = 0.05; // ゴールド会員は5%
} else if ("SILVER".equals(memberRank)) {
rate = 0.03; // シルバー会員は3%
}
return (int) (amount * rate);
}
}
このPointCalculatorServiceを、注文処理を担当するOrderServiceや、画面表示を担当する別のクラスから呼び出すようにします。
@Service
public class OrderService {
@Autowired
private PointCalculatorService pointService;
public void processOrder(int amount, String userRank) {
// 共通サービスを呼び出してポイント計算
int points = pointService.calculatePoints(amount, userRank);
System.out.println("注文処理:付与ポイントは " + points + "pt です。");
// この後、DBへの保存処理などが続く...
}
}
このように、「部品化」して組み合わせる設計思想を持つことで、大規模なアプリケーションでも混乱することなく開発を続けることができます。これは「DRY原則(Don't Repeat Yourself:同じことを繰り返さない)」という、プログラミングにおける鉄則を実践していることになります。
6. トランザクション管理の境界線:@TransactionalをServiceに置く理由
Javaの業務アプリケーション開発において、最も慎重に扱うべきなのがトランザクション管理です。トランザクションとは、複数の処理を「全て成功させるか、全て失敗させるか(最初に戻すか)」という一塊の作業単位のことです。
なぜこの管理をControllerではなくServiceで行うのでしょうか? それは、Service層こそが「業務の一つの区切り」を定義している場所だからです。
トランザクションが正しくない例
銀行振込で、「自分の口座から1万円減らす処理」は成功したが、エラーが発生して「相手の口座に1万円増やす処理」が失敗したとします。もしここで処理が止まると、消えた1万円はどこへ行ったのか?という深刻な問題になります。これを防ぐために、一連の処理を一つのトランザクションで包み、途中で失敗したら「全てなかったことにする(ロールバック)」必要があります。
Spring Frameworkなどのフレームワークでは、@Transactionalというアノテーションをメソッドに付与するだけで、この管理を自動で行ってくれます。これをService層に置く理由は以下の通りです。
- データの整合性を守る: 複数のテーブルを更新する際、Serviceメソッド全体を一単位とすることで、不整合なデータが残るのを防ぎます。
- Controllerの関心事ではない: Controllerはリクエストの受付窓口であり、データベースの接続状態やコミット・ロールバックといった細かな制御を意識すべきではありません。
- 再利用時の安全確保: Serviceがトランザクションを管理していれば、そのServiceがどこから(Web、API、バッチ)呼ばれても、データ操作の安全性は保証されます。
では、実際に@Transactionalを使用したServiceクラスの実装を見てみましょう。
@Service
public class BankTransferService {
@Autowired
private AccountRepository accountRepository;
/**
* 振込処理を一つのトランザクションとして実行
*/
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
// 1. 出金処理
Account fromAccount = accountRepository.findById(fromId);
fromAccount.withdraw(amount);
accountRepository.save(fromAccount);
// 何らかの異常(ネットワークエラーや例外)が発生したと仮定
if (amount > 1000000) {
throw new RuntimeException("高額振込制限によるエラー");
}
// 2. 入金処理(上記エラーが発生すると、ここには到達せず、1の処理も取り消される)
Account toAccount = accountRepository.findById(toId);
toAccount.deposit(amount);
accountRepository.save(toAccount);
System.out.println("サービス層:振込が正常に完了しました。");
}
}
このコードにおいて、もし高額振込制限のエラーが発生した場合、@Transactionalの魔法によって、既に行われた「出金処理」も自動的にデータベースからキャンセルされます。これをControllerで行おうとすると、手動で複雑なエラーハンドリングを書かなければならず、バグの原因になります。
「ビジネスロジックのまとまり=トランザクションの範囲」という原則を覚えておきましょう。Service層は、アプリケーションの信頼性を支える最後の砦なのです。
このように、Service層を正しく理解し活用することは、単に「コードを綺麗にする」だけでなく、「安全で、拡張しやすく、メンテナンス性の高いシステム」を作るための必須条件と言えます。初心者のうちは面倒に感じるかもしれませんが、小さなプログラムから意識して分割していくことで、プロフェッショナルなJavaエンジニアへと近づくことができるでしょう。
7. ユニットテストが劇的に楽になる!依存関係の切り離しとモック化
エンジニアが開発を進める中で、避けては通れないのがユニットテスト(単体テスト)です。プログラムが正しく動くかを自動で検証する仕組みですが、Controllerにロジックが詰まっていると、このテストが非常に困難になります。なぜなら、Controllerをテストするには「HTTPリクエストを擬似的に送る」「Webサーバーを起動する」といった大掛かりな準備が必要になるからです。
しかし、ロジックをService(サービス)層に切り出しておけば、状況は一変します。Serviceクラスは純粋なJavaクラス(POJO)として設計されるため、Web環境を必要とせず、高速かつ簡単にテストを実行できるのです。ここで重要になる概念が、依存関係の切り離しとモック化です。
例えば、ServiceがRepository(データベース操作)に依存している場合、そのままテストをすると本物のデータベースにデータを書き込んでしまいます。これではテストのたびにデータが汚れてしまいますし、実行速度も遅くなります。そこで、本物のRepositoryのふりをする「偽物のオブジェクト(モック)」を注入します。
モック化(Mocking)とは?
テスト対象のクラスが依存している別のクラスを、テスト用の「身代わり」に差し替えることです。「もしこのメソッドを呼んだら、この値を返す」という動きを自由に定義できるため、エラーが発生するパターンや、特定の条件を狙い撃ちしたテストが容易になります。
それでは、Spring Bootでよく使われるライブラリ「Mockito」を活用した、Serviceクラスのテストコード例を見てみましょう。
【テスト対象:割引計算を行うService】
@Service public class DiscountService { @Autowired private MemberRepository memberRepository;
public int applyDiscount(Long memberId, int price) {
// DBから会員情報を取得(ここをモックにしたい)
String rank = memberRepository.findRankById(memberId);
if ("VIP".equals(rank)) {
return (int) (price * 0.8); // VIPは20%引き
}
return price;
}
}
【テストコード:JUnitとMockitoの利用】
@ExtendWith(MockitoExtension.class) public class DiscountServiceTest {
@Mock
private MemberRepository memberRepository; // 偽物のリポジトリ
@InjectMocks
private DiscountService discountService; // テスト対象にモックを注入
@Test
void VIP会員の場合に20パーセント割引されること() {
// 準備:memberIdが1のとき、"VIP"を返すように設定
when(memberRepository.findRankById(1L)).thenReturn("VIP");
// 実行
int result = discountService.applyDiscount(1L, 1000);
// 検証
assertEquals(800, result);
}
}
このように、Service層を分離しておくことで、複雑な計算ロジックだけを抽出して徹底的に検証できます。不具合の早期発見につながるだけでなく、「自分の書いたコードが正しい」という自信を持って開発を進められるようになります。テストが書きやすいコードは、必然的に「良い設計」になっている証拠でもあります。
8. 良いServiceクラスを作るための設計指針とパッケージ構成のコツ
Serviceクラスを作れば何でも解決するわけではありません。作り方を間違えると、Serviceクラス自体が数千行に膨れ上がり、結局Controllerと同じような管理不全に陥る「Fat Service」という問題が発生します。保守性の高いシステムを維持するためには、いくつかの設計指針を守る必要があります。
1. インターフェースを利用して抽象化する
大規模なプロジェクトでは、Serviceを「Interface(インターフェース)」と「Impl(実装クラス)」に分ける手法がよく取られます。これにより、「どのような機能があるか」という定義と、「どう実現するか」という中身を分離できます。将来的にアルゴリズムを丸ごと差し替える場合や、複数の実装を切り替える場合に非常に有利に働きます。
2. 状態を持たせない(ステートレス)
Serviceクラスのフィールドに、特定のユーザーに紐づくデータや計算途中の値を保存してはいけません。Serviceは複数のリクエストから同時に利用されるため、クラス内にデータを持たせると、他のユーザーのデータが混ざってしまうなどの重大なバグ(スレッドセーフではない状態)を引き起こします。データは必ずメソッドの引数で渡し、戻り値で受け取るようにしましょう。
3. パッケージ構成(フォルダ分け)の定石
プロジェクトが大きくなると、どこに何があるか分からなくなります。Java開発における標準的なパッケージ構成を整理しましょう。役割ごとにディレクトリを分けることで、開発者が迷わずに目的のファイルに辿り着けるようになります。
【標準的なパッケージ構成例】
com.example.myapp ├── controller // リクエストの受付、バリデーション、レスポンス返却 ├── service // ビジネスロジックの定義(インターフェース) │ └── impl // ロジックの具体的な実装クラス ├── repository // DBアクセス(SQL発行、データの読み書き) ├── model // データの入れ物(Entity, DTO) └── config // アプリの設定(セキュリティ設定など)
この構成に従うことで、新しい機能を追加する際に「どこに何を書くべきか」というルールが明確になります。例えば「ポイント付与機能を追加して」と言われたら、まずはserviceにインターフェースを作り、service.implにロジックを書き、controllerからそれを呼び出す、という手順が自然と導き出されます。
また、ServiceからServiceを呼び出すことも可能です。例えば「注文Service」が「在庫Service」を利用するといった形です。このように小さな部品を組み合わせて大きな機能を作ることで、一つひとつのクラスを小さく、シンプルに保つことができます。
9. まとめ:保守性の高いSpring Boot開発はService層の理解から
本記事の締めくくりとして、これまでの内容を振り返りましょう。Java、特にSpring Bootを用いたモダンな開発において、Service層の導入は単なる慣習ではなく、システムを安全に維持し続けるための必然の選択です。
Controllerはあくまで「窓口」として、ユーザーからの入力を受け取って適切な場所へ案内する仕事に専念させます。一方で、複雑なビジネスルールやデータの加工、データの整合性を守るためのトランザクション管理は、すべてService層が責任を持って引き受けます。この「役割分担」が、後の修正を楽にし、バグの少ない強固なアプリケーションを生み出します。
初心者のうちは、ファイルを分ける手間が手間に感じるかもしれません。しかし、数ヶ月後に仕様変更が発生したとき、あるいはチームメンバーと協力して開発を行うとき、この設計の真価を実感することになるでしょう。まずは、小さな計算処理ひとつからでも構いません。「これはControllerの仕事かな?それともServiceかな?」と自問自答しながら、コードを書いてみてください。その積み重ねが、熟練のエンジニアへの道に繋がっています。
今回紹介した三層アーキテクチャの考え方は、Java以外の言語(C#やPHP, Pythonなど)でも広く応用されている普遍的なテクニックです。この知識を武器に、ぜひ大規模な開発現場でも通用する「美しいコード」を目指していきましょう。