Java開発の脱・初心者!Controllerにビジネスロジックを書いてはいけない理由と設計の極意
新人
「先輩、JavaのWeb開発でController(コントローラー)を作っているんですが、計算処理やデータベースへの保存命令を全部ここに書いても大丈夫ですか?」
先輩
「それは『Fat Controller(太ったコントローラー)』と呼ばれる、保守が大変になる書き方だね。結論から言うと、Controllerにビジネスロジックを書いてはいけないんだ。」
新人
「えっ、そうなんですか? どこに何を書けばいいのか、役割分担がよくわからなくて…。」
先輩
「プログラムの整理整頓は、開発の基本だよ。なぜ分ける必要があるのか、Service(サービス)という役割と一緒に詳しく解説していこう!」
1. なぜControllerにビジネスロジックを書いてはいけないのか?
JavaのWebアプリケーション開発、特にSpring Bootなどのフレームワークを利用する際、もっとも陥りやすい罠が「Controller(コントローラー)にすべての処理を書いてしまうこと」です。まずは、なぜこれが「禁じ手」とされているのかを理解しましょう。
ビジネスロジックとは?
そもそもビジネスロジックとは、そのアプリケーション特有の「計算」「判断」「ルール」のことを指します。例えば、ネットショップのシステムであれば「商品の在庫があるか確認する」「会員ランクに応じて割引率を変える」「合計金額を計算する」といった処理がビジネスロジックに該当します。これらはシステムの「心臓部」と言える重要な部分です。
Controller本来の仕事は「交通整理」
Controllerの役割は、例えるならホテルの受付係です。お客様(ユーザー)からのリクエストを受け取り、適切な部屋(画面や処理)へ案内し、結果を返すのが仕事です。もし受付係が、裏方で料理を作ったり、部屋の掃除まで始めたりしたら、受付は大混乱してしまいますよね?
プログラムも同じです。Controllerが「計算」や「データ加工」まで抱え込んでしまうと、以下のような問題が発生します。
- コードが長すぎて読めなくなる: 1つのファイルが数千行に膨れ上がり、どこに何が書いてあるか分からなくなります。
- 再利用ができない: 画面からのリクエスト以外(例えばタイマー実行や別の機能)から同じ計算を使い回したくても、Controllerに依存しているため呼び出せません。
- テストが困難になる: 画面の動きとロジックが密着しているため、計算処理だけが正しいかどうかを自動テストで確認するのが非常に難しくなります。
プログラミング未経験の方にとって、「動けばいいじゃないか」と思うかもしれません。しかし、大規模なシステムや長く運用するシステムでは、この「整理整頓」ができていないだけで、修正に膨大な時間とコストがかかるようになってしまうのです。
2. ControllerとServiceの役割を明確に分けるメリット
Controllerからビジネスロジックを追い出し、Service(サービス)という新しい役割のクラスを作ることで、プログラムの品質は劇的に向上します。ここでは、役割を分けることによる具体的なメリットを解説します。
責務の明確化(単一責任の原則)
それぞれのクラスが「自分の仕事だけ」に集中するようになります。これを専門用語で「責務の分離」と呼びます。
| クラス名 | 主な役割(仕事内容) |
|---|---|
| Controller | リクエストの受付、入力チェック(形式のみ)、画面遷移の制御 |
| Service | 複雑な計算、データの加工、DB保存の順序制御(ビジネスロジック) |
メンテナンス性の向上
例えば、「消費税率が変わったので計算式を直したい」となったとき、ControllerとServiceが分かれていれば、迷わずServiceクラスの中身だけを確認すれば済みます。画面の表示方法(HTMLやJSONなど)が変わっても、ロジック部分には影響を与えません。このように、変更に強いプログラムになります。
テストコードの書きやすさ
Javaの開発現場では、プログラムが正しく動くかを検証する「ユニットテスト(単体テスト)」を自動化することが一般的です。Serviceにロジックがまとまっていれば、ブラウザを起動することなく、特定の数値を入れたら期待通りの結果が返ってくるかを一瞬で検証できます。
良い例:役割が分かれたコードのイメージ
まずは、役割分担を意識したシンプルなServiceクラスの例を見てみましょう。ここでは消費税計算を行うロジックを担当させています。
package com.example.demo.service;
import org.springframework.stereotype.Service;
/**
* お会計に関するロジックを担当するサービス
*/
@Service
public class CalcService {
// 消費税率(定数)
private static final double TAX_RATE = 1.1;
/**
* 税込み価格を計算するビジネスロジック
* @param price 税抜き価格
* @return 税込み価格
*/
public int calculateTaxIncludedPrice(int price) {
// 単純な計算だが、これが「ビジネスロジック」の基本
double result = price * TAX_RATE;
return (int) result;
}
}
次に、このServiceを呼び出すController側のコードです。Controllerは「リクエストを受け取って、Serviceに仕事を頼むだけ」になっています。
package com.example.demo.controller;
import com.example.demo.service.CalcService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PriceController {
// Serviceを注入(DI)して使えるようにする
@Autowired
private CalcService calcService;
@GetMapping("/calculate")
public String showResult(@RequestParam("price") int price, Model model) {
// ロジック自体はServiceに丸投げする!
int finalPrice = calcService.calculateTaxIncludedPrice(price);
// 画面に渡すデータをセット
model.addAttribute("result", finalPrice);
// 結果画面を表示
return "resultView";
}
}
3. 責務分離ができていない「Fat Controller」の問題点
役割分担を無視して、Controllerにすべてを書き込んでしまった状態を「Fat Controller(太ったコントローラー)」と呼びます。これは開発現場では「アンチパターン(やってはいけない悪い例)」の代表格です。
「Fat Controller」が招く悲劇
未経験の方や初心者が、1つのControllerに「入力チェック」「データベース接続」「複雑な計算」「メール送信」「ログ出力」「画面遷移」をすべて書いたとします。すると以下のような地獄が待っています。
- 可読性の低下: プログラムを読むときに「どこで計算していて、どこで画面を変えているのか」を判別するために、スクロールを何度も繰り返さなければなりません。
- デバッグの困難さ: バグが出たとき、問題の切り分けができません。「入力データの受け取りに失敗しているのか?」それとも「計算ロジックが間違っているのか?」を特定するのに時間がかかります。
- 重複コードの温床: 他の画面でも同じ計算をしたい場合、Controllerにロジックが埋まっているとコピー&ペーストするしかありません。後でロジックが変わった際、コピーした箇所すべてを修正し忘れるというミスが多発します。
悪い例:すべてをControllerに詰め込んだ「Fat Controller」
以下のコードは、初心者がやってしまいがちな「良くない例」です。すべての処理が1つのメソッドに詰め込まれています。
@Controller
public class BadOrderController {
@PostMapping("/order")
public String processOrder(@RequestParam("itemId") int itemId, @RequestParam("amount") int amount, Model model) {
// 1. 入力チェック(ビジネスルールに近いもの)
if (amount <= 0) {
model.addAttribute("error", "数量は1以上にしてください");
return "orderError";
}
// 2. データベース代わりの在庫確認(本来はServiceやRepositoryの役割)
// ここで複雑なSQLを組み立てたりする...
int stock = 10; // 仮の在庫
if (amount > stock) {
model.addAttribute("error", "在庫が足りません");
return "orderError";
}
// 3. 金額計算(ロジック)
int unitPrice = 1000;
int totalPrice = unitPrice * amount;
// 4. 保存処理のシミュレーション
System.out.println("注文を保存しました。商品ID:" + itemId + " 合計:" + totalPrice);
// 5. 画面表示の準備
model.addAttribute("total", totalPrice);
return "orderSuccess";
}
}
このコードの問題は、「注文処理のルール(1つ以上、在庫チェック、金額計算)」がControllerの中に閉じ込められていることです。もし「スマホアプリ版の注文機能」を新しく作ることになったら、このControllerのコードをまたゼロから書き直す必要が出てきます。
解決策:ロジックを独立させる
プログラミングにおいて、「独立性」は非常に重要です。Controllerはあくまで「Webの世界(HTTPリクエストなど)」と「プログラムの世界」の仲介役に徹し、純粋なビジネスのルールはServiceという「箱」の中に隠蔽(いんぺい)すべきなのです。これにより、誰が見てもどこに何があるか一目でわかる「美しいコード」になります。
初心者のうちは、ファイルを分けるのが面倒に感じるかもしれません。しかし、「この処理は計算かな?それとも案内かな?」と立ち止まって考える癖をつけることが、エンジニアとしてのスキルアップに繋がります。
4. 【図解】リクエストからDB処理までのデータフローと境界線
システム開発において、データの流れ(データフロー)を正しく理解することは、バグの少ない綺麗なコードを書くための第一歩です。 ユーザーがブラウザでボタンをクリックしてから、データベース(DB)に情報が保存され、再び画面に応答が返るまで、データはどのような道を辿るのでしょうか。 ここでは、Spring Bootなどのフレームワークで一般的に採用されている「3層アーキテクチャ」に基づいた流れを解説します。
リクエストの旅路と各層の役割
データは、以下の図のような順序で各コンポーネントを通過していきます。 それぞれの境界線には明確な「役割の壁」があり、それを超えて直接やり取りすることは原則として禁止されています。
この流れを「レストラン」に例えると非常に分かりやすくなります。
- 1. Controller(受付・接客): 注文内容を確認し、キッチンに伝えます。お客様に直接接する窓口です。
- 2. Service(シェフ・調理): レシピに従って調理を行います。複数の食材を組み合わせたり、味付けを調整したりする「中心的な工程」です。
- 3. Repository(パントリー・食材管理): 冷蔵庫から食材を取り出したり、余った食材を保管したりします。DBとのやり取りだけに特化した担当です。
なぜ境界線を守らなければならないのか?
もし、Controllerが直接DBにデータを保存しにいったらどうなるでしょうか。 それは、受付係が突然包丁を持って調理場に乱入するようなものです。 キッチンの中が混乱するだけでなく、受付にお客様が来ても対応できなくなってしまいます。
境界線を守る最大の理由は「依存の最小化」です。 例えば、DBをMySQLからPostgreSQLに変更することになっても、境界線が守られていれば修正範囲はRepository層だけに収まります。 ControllerやServiceは、裏側のDBが何であるかを気にする必要がないのです。 この「他人の仕事に首を突っ込まない」という設計思想が、大規模開発を支える重要なポイントとなります。
5. Service層を導入する際の基本的なパッケージ構成と命名規則
プロジェクトの規模が大きくなると、クラスの数も膨大になります。 Javaの世界では、関連するクラスをまとめる「パッケージ」という仕組みを使って整理整頓を行います。 初心者の方は、どこにどのファイルを置けばいいのか迷うことが多いですが、標準的な構成を知っておけば迷いは消えます。
標準的なディレクトリ構成(Spring Bootの例)
一般的には、ドメイン名(会社のURLの逆順など)をベースにしたパッケージ名を使用します。 例えば「com.example.myshop」というプロジェクトであれば、以下のような構成になります。
src/main/java
└── com.example.myshop
├── controller // 画面遷移やリクエスト制御を担当
├── service // ビジネスロジック、業務処理を担当
├── repository // データベース操作を担当
├── entity // DBのテーブルに対応するデータクラス
└── dto // データの受け渡し専用のクラス(Data Transfer Object)
命名規則のルールとコツ
クラス名を見ただけで、そのクラスがどの層に属し、何をするものなのかが分かるように命名するのが鉄則です。
| 層 | 接尾辞(サフィックス) | クラス名の例 |
|---|---|---|
| Controller | Controller | UserRegistrationController |
| Service | Service | UserRegistrationService |
| Repository | Repository | UserRepository |
また、Serviceクラス内のメソッド名も、ビジネス(業務)の内容が直感的に分かる名前にします。
単に saveData() とするのではなく、registerNewUser()(新規ユーザー登録)や applyDiscount()(割引適用)といった、「何を行うか」という振る舞いに焦点を当てた名前を付けましょう。
6. @Controllerと@Serviceを正しく使い分ける実装のポイント
Spring Frameworkを使用する場合、クラスに付けるアノテーションによって、そのクラスの性格が決まります。
@Controller と @Service は、どちらもSpringの管理対象(Bean)であることを示すものですが、意味合いが大きく異なります。
@Controllerの主な役割と実装パターン
Controllerの最大の仕事は「HTTPリクエストのパラメータを受け取り、Serviceに処理を繋ぎ、結果をビュー(HTML等)に渡す」ことです。 ここでは、複雑な計算やDBのトランザクション管理は一切行いません。
package com.example.myshop.controller;
import com.example.myshop.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class UserRegistrationController {
private final UserService userService;
// コンストラクタによるDI(推奨される方法)
public UserRegistrationController(UserService userService) {
this.userService = userService;
}
@PostMapping("/register")
public String registerUser(@RequestParam String name, @RequestParam int age, Model model) {
// Controllerは入力値の受け取りと、大まかな流れだけを管理
if (age < 0) {
model.addAttribute("message", "年齢が正しくありません");
return "errorView";
}
// 実際の登録処理という「重い仕事」はServiceに任せる
userService.register(name, age);
model.addAttribute("name", name);
return "successView";
}
}
@Serviceの主な役割と実装パターン
Serviceは「業務ロジックの実行」に専念します。 例えば、登録時に「特定の条件でポイントを付与する」といったルールがあれば、ここに記述します。 また、複数のテーブルを更新する場合の「トランザクション管理(すべて成功するか、すべて失敗するか)」もServiceの重要な仕事です。
package com.example.myshop.service;
import com.example.myshop.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
/**
* ユーザー登録のビジネスロジック
*/
@Transactional // 処理が途中で失敗したら自動でロールバック(巻き戻し)する
public void register(String name, int age) {
// 1. 重複チェックなどの業務ルール
if (userRepository.findByName(name) != null) {
throw new RuntimeException("既に登録されている名前です");
}
// 2. データの加工(例:18歳以上なら初期ポイントを付与)
int initialPoint = (age >= 18) ? 100 : 0;
// 3. データの保存実行
userRepository.save(name, age, initialPoint);
// 4. ログ出力や外部連携など、付随する業務処理
System.out.println("新しいユーザーが登録されました: " + name);
}
}
実務で役立つ使い分けのチェックリスト
「このコードはどっちに書くべき?」と迷ったときは、以下のリストを確認してください。
- URLのルーティング設定
HttpSessionなどのWeb固有の操作- 簡単な型チェック(数値か、空でないか等)
- どの画面(HTML)を出すかの決定
- 複雑な計算、税金や送料の算出
- DBから取得したデータのフィルタリングや並び替え
- 複数テーブルを跨ぐ一貫した更新処理
- 外部APIとの通信ロジック
このように役割を明確に分けることで、1つのクラスが巨大化するのを防ぎ、変更に強い「柔軟なシステム」を構築することができます。 Javaエンジニアとしてステップアップするためには、まずこの「線を引く意識」を常に持つことが大切です。
さらなる応用:インターフェースの活用
現場によっては、UserService という名前のインターフェースを作り、その実装クラスを UserServiceImpl とすることもあります。
これは、テスト時にダミーの処理(Mock)に差し替えやすくしたり、将来的にロジックを大幅に入れ替える可能性に備えたりするための工夫です。
初心者のうちはクラスだけでも十分ですが、プロの現場では「インターフェースで繋ぐ」という考え方がよく使われることも覚えておくと良いでしょう。
プログラムを書くことは、ただコンピュータを動かすことではありません。 数ヶ月後の自分や、一緒に働くチームメイトが読んだときに、迷わずに構造を理解できる「親切な設計」を心がけましょう。 それが「脱・初心者」への最短ルートです。
7. 疎結合な設計を実現するInterfaceとDI(依存性の注入)の活用
これまでの解説で、Controllerからビジネスロジックを切り離し、Service層にまとめる重要性をお伝えしました。しかし、ただクラスを分けるだけでは不十分な場合があります。より高度で柔軟なシステムを作るためには、「インターフェース(Interface)」と「DI(Dependency Injection:依存性の注入)」の活用が欠かせません。
密結合と疎結合の違い
例えば、Controllerの中で private UserService service = new UserServiceImpl(); のように直接インスタンスを生成してしまうと、Controllerは特定のクラス(実装)に強く依存してしまいます。これを「密結合」と呼びます。密結合な状態では、Serviceの内容を差し替えたいときにControllerまで修正が必要になり、変更に弱いシステムになってしまいます。
一方で、インターフェースを介してやり取りを行う設計を「疎結合」と呼びます。Controllerは「何ができるか(インターフェース)」だけを知っており、「具体的にどう処理するか(実装クラス)」はSpring Frameworkが外から注入してくれる仕組みです。これがDIの本質です。
実装例:インターフェースを用いたサービス設計
まずは、ビジネスロジックの「規約」を定義するインターフェースを作成します。
package com.example.demo.service;
/**
* 注文処理に関するインターフェース
*/
public interface OrderService {
/**
* 注文を確定させる
* @param itemId 商品ID
* @param quantity 数量
*/
void placeOrder(int itemId, int quantity);
}
次に、このインターフェースを実際に実装するクラスを作成します。
package com.example.demo.service;
import org.springframework.stereotype.Service;
/**
* 通常の注文処理を行う実装クラス
*/
@Service
public class OrderServiceImpl implements OrderService {
@Override
public void placeOrder(int itemId, int quantity) {
// 実際の注文処理ロジック
System.out.println("商品ID:" + itemId + "を" + quantity + "個注文しました。");
}
}
最後に、Controller側ではインターフェース型で受け取ります。Spring BootのDIコンテナが自動的に OrderServiceImpl を探してセットしてくれます。
package com.example.demo.controller;
import com.example.demo.service.OrderService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
private final OrderService orderService;
// コンストラクタ注入(DI)
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/order")
public String executeOrder(@RequestParam int itemId, @RequestParam int quantity) {
orderService.placeOrder(itemId, quantity);
return "注文が完了しました。";
}
}
このように設計することで、例えば「テスト用のモックサービス」や「期間限定の特別注文ロジック」に変更したい場合でも、Controllerのコードを一行も変えることなく、設定やアノテーションの変更だけで対応が可能になります。
8. トランザクション管理をService層で行うべき理由
業務アプリケーションにおいて最も重要な概念の一つが「トランザクション管理」です。トランザクションとは、複数の処理を一つのまとまりとして扱い、「すべて成功するか、すべて失敗(取り消し)するか」を保証する仕組みです。
なぜControllerではなくServiceなのか
トランザクションは、ビジネスルールの一貫性を守るためのものです。例えば、銀行振込のシステムを考えてみましょう。「自分の口座から残高を減らす」という処理と「相手の口座に残高を増やす」という処理は、必ずセットで行われなければなりません。片方だけ成功して、もう片方がエラーになると、お金が消えてしまうという重大な問題が発生します。
Controllerの役割は「画面からの入力を受け取ること」であり、業務の一貫性を保証する場所ではありません。ビジネスロジックのまとまりを管理するService層に @Transactional アノテーションを付与することで、そのメソッド内で行われる複数のデータベース操作が一つのトランザクションとして保護されます。
実装例:トランザクションによる一貫性の保持
以下は、在庫を減らした後に注文履歴を保存する、典型的なServiceの実装例です。
package com.example.demo.service;
import com.example.demo.repository.InventoryRepository;
import com.example.demo.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PurchaseService {
private final InventoryRepository inventoryRepository;
private final OrderRepository orderRepository;
public PurchaseService(InventoryRepository inventoryRepository, OrderRepository orderRepository) {
this.inventoryRepository = inventoryRepository;
this.orderRepository = orderRepository;
}
/**
* 購入処理(トランザクション境界)
*/
@Transactional(rollbackFor = Exception.class)
public void purchase(int itemId, int quantity) {
// 1. 在庫を減らす
inventoryRepository.reduceStock(itemId, quantity);
// 何らかの理由でここでエラーが発生したとする(例:DB接続断)
// その場合、@Transactionalがあることで在庫の減算も自動的にキャンセルされる
// 2. 注文履歴を保存する
orderRepository.saveOrder(itemId, quantity);
}
}
例外発生時の挙動
Springの @Transactional は、メソッド内で実行時例外(RuntimeException)が発生すると、自動的にロールバック(データの巻き戻し)を実行します。Controllerにこの管理を任せてしまうと、複数のサービスを呼び出す際にトランザクションが分断されてしまい、不整合なデータがデータベースに残るリスクが高まります。
「業務の区切り=サービスのメソッド」と定義することで、安全なデータ操作を実現できるのです。
9. メンテナンス性を高めるためのControllerとServiceの責務整理
最後に、より具体的な「責務の分け方」の指針を整理しましょう。初心者が最も迷うのは、「このif文はControllerに書くべきか、Serviceに書くべきか」という点です。
Controllerが担当すべきバリデーション
Controllerで書くべきチェックは、「形式的なバリデーション」です。
例えば、「未入力ではないか」「数字が入るべき場所に文字が入っていないか」「メールアドレスの形式が正しいか」といった内容です。これらは「ビジネスのルール」以前の「データの入り口」の問題だからです。Spring Bootでは @Valid アノテーションなどを使って、これらを宣言的に記述するのが一般的です。
Serviceが担当すべきバリデーション
一方で、Serviceで書くべきチェックは、「論理的なバリデーション(業務チェック)」です。 例えば、「在庫数は足りているか」「このユーザーは退会済みではないか」「同じメールアドレスが既に登録されていないか」といった、データベースの情報と照らし合わせたり、複雑な計算を伴ったりする内容です。
DTO(Data Transfer Object)の活用
ControllerとServiceの間でデータをやり取りする際、複数の引数を渡すのではなく、専用のクラス(DTO)を作成してまとめることで、さらにメンテナンス性が向上します。
package com.example.demo.dto;
import lombok.Data;
/**
* ユーザー登録用DTO(データの入れ物)
*/
@Data
public class UserRegistrationDto {
private String username;
private String email;
private String password;
private int age;
}
このようにデータをオブジェクト化することで、将来的に「住所」や「電話番号」といった項目が増えたとしても、メソッドの引数を増やす必要がなく、DTOクラスのフィールドを追加するだけで済みます。引数の順番を間違えるといったケアレスミスも防ぐことができます。
究極の判断基準:Web以外でも使えるか?
もしあなたが「この処理をServiceに書くべきか」と迷ったら、こう自問自答してみてください。「もしこのシステムが、ブラウザからではなく、スマートフォンのアプリやコマンドラインから呼び出されるようになったとしたら、その処理は必要か?」と。
画面の遷移先やHTTPステータスコードの返却はWeb固有のものなのでControllerです。しかし、「合計金額の計算」や「データベースへの保存」は、どんな呼び出し元であっても共通で必要なはずです。それがServiceに書くべき内容です。
設計に正解はありませんが、この「共通化」と「分離」を常に意識することで、あなたの書くJavaコードは格段に読みやすく、変更に強い「プロのコード」へと進化していくはずです。