1. OOPを実務で一言で言うと
オブジェクト指向(OOP)を実務目線で一言にすると、
- 変更の影響範囲を小さくするための設計
です。
機能追加や例外仕様が増えてくると、修正するたびに「どこまで影響する?」が分からなくなって怖くなります。
OOPはその怖さを減らすために、責任の持ち主を決めて、ルールを散らさないようにします。
もう少し噛み砕くと、
- 「このルールはどこに書くべき?」が決まっている
- その結果、変更箇所が見つけやすく、修正漏れが減る
- チームでも分担しやすい
みたいな効果が出ます。
2. よくある誤解(先に潰しておく)
誤解①:OOP=継承がメイン
実務だと、継承が主役になることは多くないです。
まず効くのは、
- 混ざっているものを分ける
- ルールの置き場所を決める
この2つです。
(継承は使いどころがあるけど、今回の主役ではない、くらいでOK)
誤解②:「クラスを増やすこと」が目的
クラスを増やすのが目的ではなくて、目的はあくまで
- 変更に強くする(影響範囲を小さくする)
- ルールを散らさない
です。
もしクラスを増やした結果、逆に追いにくくなるなら、それはやりすぎです。
3. “今やばい”サイン(OOPが刺さる症状チェック)
ここからは実務の話。
次のサインが出てきたら、OOP的に「混ざりをほどく」価値が高いです。
サインA:if/switchが増殖してきた
- ステータス追加や例外仕様が入るたびに分岐が増える
- 分岐が複数箇所に広がって、修正漏れが出る
サインB:status を直接書き換える箇所が散ってる
- あちこちで
status = ...している - 「本当はこの条件では遷移できない」が守られなくなる
サインC:外部API / DB / 通知 / 監査ログが1つの処理に混ざってる
- 1つのメソッドで「状態遷移」「保存」「通知」「ログ」までやってる
- テストが怖い(本番API叩きそう、メール飛びそう)
- どこを直すべきか分かりにくい
サインD:変更が同じファイルに集中して衝突する
- 巨大なサービス/巨大なコントローラが育つ
- PRが読みづらく、レビューが重い
- 並行作業でコンフリクトが増える
4. 例題の前提(ステータス変更あるある)
この記事では「予約」みたいな特定ドメインじゃなくて、どのチームでも起きがちな ステータス変更 を例にします。
対象モデル(例)
-
Request(申請・チケット・応募・依頼など、何でもOK)
ステータス(A:英語で統一)
DRAFTSUBMITTEDAPPROVEDREJECTEDCANCELED
よくある状態遷移(例)
-
DRAFT→SUBMITTED(提出) -
SUBMITTED→APPROVED(承認) -
SUBMITTED→REJECTED(却下) -
APPROVED→CANCELED(キャンセル) -
REJECTED→SUBMITTED(修正して再提出)
例外ルール(増えがちなやつ)
ステータス変更は、だいたいこういう条件が後から追加されます。
-
権限:
SUBMITTED → APPROVEDは manager だけ -
期限:
APPROVED → CANCELEDは当日不可 -
条件:
REJECTED → SUBMITTEDは「修正済み」のときだけ
ここが増えてくると、
if ($status === ...)が増殖して、ルールが散っていきます。
次の章で、ありがちなBefore(全部まぜ)を見ます。
5. Before:全部まぜのステータス変更(ありがちアンチパターン)
まずは「よくある実装」をそのまま出します。
状態遷移のルールも、保存も、通知も、監査ログも、全部が1か所に混ざっていくパターンです。
例:
Requestのステータスを変更する処理(Laravelっぽい雰囲気)
public function updateStatus(Request $request, int $id)
{
$model = RequestModel::findOrFail($id);
$toStatus = $request->input('status');
$actor = auth()->user();
// 例外ルールが if で増殖していく
if ($toStatus === 'APPROVED' && !$actor->isManager()) {
abort(403, 'You are not allowed to approve.');
}
if ($toStatus === 'CANCELED' && $model->status === 'APPROVED' && $model->isToday()) {
abort(400, 'Cannot cancel on the same day.');
}
if ($toStatus === 'SUBMITTED' && $model->status === 'REJECTED' && !$model->isFixed()) {
abort(400, 'You must fix before resubmitting.');
}
// ステータスを直接書き換え(この手の直書きが散りがち)
$model->status = $toStatus;
$model->save();
// 通知もここでやりがち(外部処理混在)
Notification::route('slack', config('services.slack.webhook_url'))
->notify(new StatusChangedNotification($model->id, $model->status));
// 監査ログもここでやりがち(共通処理混在)
AuditLog::create([
'request_id' => $model->id,
'changed_by' => $actor->id,
'to_status' => $toStatus,
]);
return response()->json(['ok' => true]);
}
何が辛いか(現場あるある)
この形、最初は速いですが、育つとしんどくなります。
- ifが増殖:ステータスや例外仕様が増えるたびに分岐が増えて、修正漏れが起きる
- ルールが散る:同じ遷移ルールが別の場所にも生まれやすい(「どれが正?」になる)
- テストが怖い:保存・通知・ログが混ざって、本番処理が走りそうで単体テストしづらい
- 共通処理が漏れる:ログや監査がコピペになって、例外ケースだけ抜けが出る
- 衝突が増える:みんなが同じメソッドを触るので、PRが重くなりコンフリクトが増える
次の章では、これを「ただ分割する」だけじゃなく、責任の持ち主を決める形にほどいていきます。
6. Afterの方針:ただ分割するんじゃなく「責任の持ち主」を決める
5章のBeforeは「全部が1か所に混ざっている」状態でした。
6章では、これを ただ分割するだけ ではなく、“責任の持ち主”を決める 形にほどいていきます。
ここでのゴールはシンプルです。
- ステータス変更のルールが、1か所を見れば分かる
- 保存・通知・監査ログが混ざらず、テストしやすくなる
- 変更が1ファイルに集中しにくくなり、チームで衝突しづらくなる
6.1 「混ざり」をラベル付けする
まず、Beforeの処理に混ざっているものをラベルで分けます。
- ルール:どのステータスからどこへ遷移できる?条件は?(権限/期限/修正済み 等)
- 保存:DBに更新を書き込む
- 通知:Slack/メールなどに知らせる
- 監査:誰がいつ何をしたか残す(監査ログ)
- 流れ(ユースケース):上の順番をまとめて実行する(配線係)
このラベル付けができると、「どこを直すべきか」が見えます。
6.2 置き場所(責任の持ち主)を決める
次に、それぞれの責任を“持ち主”に割り当てます。
ルールの持ち主:モデル(ドメイン)
- 例:
Request自身が「この遷移はOK/NG」を判断する - 外から
status = ...と直接書き換えるのではなく、遷移用の操作(メソッド) を通す
(この後の章で transitionTo() を紹介します)
流れの持ち主:ユースケース(Service)
- 例:ステータス変更の「順番」を担当する
- 「ルール確認 → 更新 → 保存 → 監査 → 通知」の配線係
保存の持ち主:Repository(またはEloquentのsaveでもOK)
- DBに書く責務はここに閉じる
- “どのテーブルをどう更新するか” が散らばらない
通知の持ち主:Notifier
- Slack/メール等の連携をここへ
- 「通知が必要なタイミング」をユースケースから呼ぶ
監査の持ち主:AuditLogger(監査サービス)
- “成功したら必ず記録する” をここで保証したい
- コピペで散らすと漏れるので、責務として分ける
6.3 Afterの完成イメージ(役割だけ覚えればOK)
Afterはざっくりこうなります。
-
Request(モデル):遷移ルールを持つ(例:
transitionTo()) - ChangeRequestStatusService(ユースケース):処理の流れを組み立てる(配線係)
- RequestRepository(保存):DB更新
- RequestNotifier(通知):Slack/メール通知
- RequestAuditLogger(監査):監査ログ
ポイント:
「ステータスを直接書き換える場所」を増やさず、“変更の入口”を一本化すること。
6.4 ここまでで得られるメリット(現場あるあるに効く)
- if増殖が止まりやすい:ルールは“ルールの置き場”に集まる
- 修正漏れが減る:変更箇所が見つけやすい
- テストが楽になる:ルールだけ単体でテストしやすい(外部や通知が混ざらない)
- 共通処理の漏れが減る:監査ログなどを「必ず通る道」に置ける
- 衝突が減る:担当が分かれて並行作業しやすい
次の章では、まずコアとなる 「ルールの入口を一本化する」 を、transitionTo() のコードで見せます。
7. ルールはモデルに寄せる:transitionTo()(状態遷移の入口を一本化)
6章で決めた方針の中で、いちばん効くのがここです。
ステータス変更のルールを1か所に集めて、変更の入口を一本化する
Beforeでは、あちこちで status = ... が起きがちでした。
Afterでは、外側(Controller/Service)が直接 status を触らずに、必ず
transitionTo($toStatus, $actor)
を通すようにします。
7.1 ここでやりたいこと(2つだけ)
- 遷移できるか判断する(ルール)
- OKならステータスを更新する(状態の更新)
重要:保存(DB)や通知、監査ログはここではやりません。
transitionTo()は「ルールと状態遷移」に集中させます。
7.2 例:RequestModel に transitionTo() を用意する
以下はLaravelっぽい例です(Eloquentモデル想定)。
<?php
use Illuminate\Database\Eloquent\Model;
use DomainException;
class RequestModel extends Model
{
// 例:ここではシンプルに string で扱う(実務では Enum でもOK)
// status: DRAFT / SUBMITTED / APPROVED / REJECTED / CANCELED
/**
* ステータスを遷移させる(ルール込み)
*
* @throws DomainException 遷移できない場合
*/
public function transitionTo(string $toStatus, AdminUser $actor): void
{
$fromStatus = $this->status;
// 1) 同じステータスへの遷移は弾く(例)
if ($fromStatus === $toStatus) {
throw new DomainException('Already in that status.');
}
// 2) ルール:SUBMITTED -> APPROVED は manager のみ
if ($fromStatus === 'SUBMITTED' && $toStatus === 'APPROVED') {
if (!$actor->isManager()) {
throw new DomainException('Only managers can approve.');
}
}
// 3) ルール:APPROVED -> CANCELED は当日不可
if ($fromStatus === 'APPROVED' && $toStatus === 'CANCELED') {
if ($this->isToday()) {
throw new DomainException('Cannot cancel on the same day.');
}
}
// 4) ルール:REJECTED -> SUBMITTED は修正済みのみ
if ($fromStatus === 'REJECTED' && $toStatus === 'SUBMITTED') {
if (!$this->isFixed()) {
throw new DomainException('You must fix before resubmitting.');
}
}
// 5) それ以外の遷移も許可/不許可を制御したいならここで制限
// 例:許可される遷移の一覧を作って、一覧にないものは弾く…など
// OKなら更新(ここが「入口の一本化」の核心)
$this->status = $toStatus;
}
// 例:期限判定(実装はプロダクト都合)
public function isToday(): bool
{
// ...
return false;
}
// 例:修正済み判定(実装はプロダクト都合)
public function isFixed(): bool
{
// ...
return true;
}
}
7.3 こうすると何が嬉しい?(現場あるあるへの効き方)
✅ ルールが散らばらない
「遷移ルールどこ?」と聞かれたら transitionTo() を見ればいい。
分岐が増えても、増える場所が1か所に寄ります。
✅ status = ... の直書きが減る(事故が減る)
入口が一本化されると、
「本当は遷移できない条件なのに status だけ変わった」みたいな事故を減らせます。
✅ ルールのテストがやりやすい
transitionTo() は外部APIも通知も触らないので、
ルールだけを単体でテストしやすいです。
7.4 ここでの注意点(やりすぎ防止)
- ルールが大きくなりすぎたら、
transitionTo()の中を- 「遷移表(許可される組み合わせ)」
- 「ルール判定メソッドに分割」
みたいに整理していけばOKです。
- ただし最初から凝った仕組みにせず、困ってからで十分です。
次の章では、この transitionTo() を使って、
ユースケース(配線係)が “保存・監査・通知” をどう順番にまとめるか を見せます。
8. ユースケースは「配線係」:保存・監査・通知を順番にまとめる
7章で、ステータス変更のルールと入口を transitionTo() に集約しました。
次は、その変更を「実務として成立させる」ために、周辺処理をまとめます。
ここで言う ユースケース(UseCase / Service) は、
- ルールを判断して(モデルに任せる)
- 状態を更新して
- 保存して
- 監査ログを残して
- 通知する
という 順番(流れ)を組み立てる配線係 です。
ポイント:
ユースケースは「ルールを持つ場所」ではなく、
ルールを呼び出して、周辺処理を正しい順番で実行する場所です。
8.1 ユースケースでやること(責務の境界)
ユースケースの責務は、ざっくりこの3つです。
- 取得:対象を取得する(RepositoryやModel取得)
-
遷移:
transitionTo()を呼ぶ(ルール判定+状態更新) - 周辺処理:保存・監査・通知を確実に実行する
逆に、ユースケースでやらないことも決めておきます。
- ルールの詳細(→モデル側)
- 通知の送り方の詳細(→Notifier側)
- 監査ログの永続化の詳細(→AuditLogger側)
8.2 例:ChangeRequestStatusService(Laravelっぽい例)
ここでは、Repository/Notifier/AuditLogger を使う形で例を書きます。
(実プロダクトでは save() 直でもOK。考え方を見せるのが目的)
<?php
use Illuminate\Support\Facades\DB;
class ChangeRequestStatusService
{
public function __construct(
private RequestRepository $repo,
private RequestAuditLogger $audit,
private RequestNotifier $notifier,
) {}
public function handle(int $requestId, string $toStatus, AdminUser $actor): void
{
// 保存と監査は「一緒に成功/失敗」してほしいのでトランザクションに入れる
DB::transaction(function () use ($requestId, $toStatus, $actor) {
$request = $this->repo->findOrFail($requestId);
$fromStatus = $request->status;
// ルール判定+状態更新(入口は一本化)
$request->transitionTo($toStatus, $actor);
// 保存
$this->repo->save($request);
// 監査ログ(成功した変更は必ず残す)
$this->audit->recordStatusChanged(
requestId: $request->id,
actorId: $actor->id,
fromStatus: $fromStatus,
toStatus: $toStatus,
);
});
// 通知は「DBが確定してから」送りたいので、トランザクションの外に出す
// ※ 失敗時に通知だけ飛ぶ事故を避ける
$this->notifier->statusChanged(
requestId: $requestId,
toStatus: $toStatus,
);
}
}
8.3 ここが実務で効くポイント
✅ 「必ず通る道」ができる
ステータス変更をするなら、このユースケースを通る。
そうすると
- 監査ログの入れ忘れ
- 保存し忘れ
- 通知の送信漏れ
みたいな事故が減ります。
✅ 変更の影響範囲が小さくなる
- ルールを変えるなら
transitionTo() - 通知を変えるなら
Notifier - 監査の形式を変えるなら
AuditLogger
と、直す場所が絞れます。
✅ テストしやすい
- ルール単体は
transitionTo()をテスト - 流れはこのユースケースをテスト(通知は偽物に差し替える、など)
8.4 トランザクションと通知の順番(よくある事故を防ぐ)
ありがちな事故:DB保存が失敗したのに通知だけ飛ぶ
→ これを避けるために、基本は
- 保存・監査はトランザクション内
- 通知はトランザクション外(DB確定後)
にすると安全です。
(より厳密にやるなら「コミット後にジョブで通知」などもありますが、まずはここまでで十分です)
次の章では、特に事故りやすい 監査ログ/ログの“漏れ”をどう防ぐか を、もう少し具体的に整理します。
9. 監査ログ・ログの“漏れ”を防ぐコツ(共通処理は散らさない)
ステータス変更は、実装が増えるほど「漏れ」が出やすいです。
特に多いのがこの2つ。
- 監査ログが残ってない(誰がいつ何をしたか追えない)
- ログがバラバラ(調査のときに追えない/粒度が統一されてない)
9.1 よくある漏れパターン
パターンA:例外パスだけ監査が抜ける
- 「この分岐のときだけ
AuditLog::create()が呼ばれてない」 - 「abort/throw の手前で return してしまって記録されない」
パターンB:ステータス更新箇所が複数あって、記録が追えない
- A画面からの変更は監査されるが、Bバッチからの変更は監査されない
- 直書き
status = ...が散って、どれが正規ルートか分からない
パターンC:ログ粒度が統一されず、調査が遅くなる
- ある処理は request_id を出すが、別処理は出してない
- ステータスの from/to がログに出てない
9.2 いちばん効く原則:「成功した変更は必ず1か所を通す」
結論はこれです。
ステータス変更が成功したら、必ずユースケース(配線係)で監査を記録する
7章の transitionTo() は「ルールと状態更新」だけ。
監査ログはユースケース側で、「保存と一緒に確定」させるのが安全です。
理由はシンプルで、
- 保存に失敗したのに監査だけ残るのも嫌
- 監査を残すのを忘れるのも嫌
だから
- 保存と監査はトランザクションで一緒に成功/失敗
- 通知はDB確定後
が基本になります。
9.3 監査ログに最低限残すべき項目(テンプレ)
実務だと「何を残すか」がブレやすいので、最低限をテンプレ化します。
-
request_id(対象ID) -
actor_id(実行者) from_statusto_status-
reason(任意:却下理由など) created_at- (可能なら)
trace_id/request_id(HTTP)(追跡用)
これが揃うと「いつ/誰が/何を/どう変えたか」が追いやすくなります。
9.4 Laravelっぽい実装例(ユースケースで確実に残す)
ChangeRequestStatusService の中で、保存と同じトランザクション内に置きます。
DB::transaction(function () use ($requestId, $toStatus, $actor) {
$request = $this->repo->findOrFail($requestId);
$fromStatus = $request->status;
$request->transitionTo($toStatus, $actor);
$this->repo->save($request);
// ✅ 成功した変更は必ずここで監査
$this->audit->recordStatusChanged(
requestId: $request->id,
actorId: $actor->id,
fromStatus: $fromStatus,
toStatus: $toStatus,
);
});
この形にしておくと、Controllerや他の呼び出し元が増えても
「監査が抜ける」可能性がかなり下がります。
9.5 “ログ”も同じ:散らさず、必要情報を統一する
アプリログも同じ考え方で、調査を楽にするなら テンプレ化が強いです。
例:ステータス変更のログに必ず入れるキー
request_idactor_idfrom_statusto_status-
result(success/failure) -
error(失敗時のみ)
ログは「たくさん出す」より、「調査に必要な情報が揃っている」方が価値が高いです。
次の章では、これらをやりすぎて「分けすぎ・増やしすぎ」で逆に追いにくくならないための
**ちょうどいい粒度の目安(やりすぎ防止)**をまとめます。
10. やりすぎ防止ガイド:分けすぎると逆に読めない
ここまでで「混ざりをほどく」「入口を一本化する」「監査/ログを漏らさない」まで見ました。
ただ、OOPはやりすぎると クラスやファイルが増えすぎて追いにくい という副作用も出ます。
この章では、ちょうどいい粒度の目安をまとめます。
10.1 まず大前提:最初から完璧を狙わない
いきなり理想形にしようとすると、
- 構造だけ立派で中身が薄い
- どこから読めばいいか分からない
- 小さい変更でもファイルをまたぐ
みたいになりがちです。
基本方針はこれでOKです。
困っている場所から、段階的にほどく
10.2 分けるべきタイミング(症状ベース)
次の症状が出たら「分ける価値」が高いです。
- 同じファイルにPRが集中して 衝突が増えた
- if/switchが増えて ルールが追えない
-
status = ...直書きが散って 事故が起きた - 保存・通知・監査が混ざって テストが怖い
- 共通処理(監査/ログ)の 入れ忘れが出た
逆に言うと、これらが起きてない段階で過剰に分ける必要は薄いです。
10.3 分けない方がいいケース(割り切りポイント)
- その処理が 小さくて変化しない
- 例外仕様が増える見込みが ほぼない
- 実装が単純で、ifも増えず、影響範囲が読める
- “一回きり”のスクリプトやバッチで、保守期間が短い
こういうケースで無理に分割すると、逆に理解コストが上がります。
10.4 ちょうどいい粒度の目安(迷ったらこれ)
判断に迷ったら、次のルールが使えます。
目安A:変更理由が違うものは分ける
- ルール変更(権限/期限)と、通知先変更(Slack→メール)は別の理由で変わる
→ 同じメソッドに混ぜない
目安B:外部と内側は分ける
- 外部API・通知・DBなどは壊れ方が違う
→ 内側(ルール)と混ぜない
目安C:「読む人」を想定して分ける
- ルールを追いたい人は
transitionTo()を見ればいい - 通知を追いたい人は Notifier を見ればいい
「見に行く場所」が自然に分かれるのが理想です。
10.5 ここまでの落としどころ(最小セット)
迷ったら、まずはこの最小セットだけで十分です。
-
状態遷移の入口を一本化(
transitionTo()) - 成功時に必ず監査が残る道を作る(ユースケースで記録)
- 保存・通知・監査を混ぜない(役割で分ける)
この3つだけでも、現場あるあるの事故率がかなり下がります。
次の章では、PRレビューでそのまま使える
チェックリスト(だれでも版 / エンジニア版) をまとめます。
11. PRレビュー用チェックリスト(コピペで使える)
この記事の内容を「実務で効く形」にするために、PRレビューで使えるチェックリストを置きます。
迷ったら、まず だれでも版 だけ見ればOKです。
11.1 だれでも版(まずはここだけ)
✅ 入口(ステータス変更のやり方)
-
status = ...の直書きが増えていない?(散っていない?) -
ステータス変更は、決まった入口(例:
transitionTo()やユースケース)を通っている?
✅ ルール(if増殖の抑制)
- 遷移ルール(権限・期限・条件)が 複数箇所に散っていない?
-
if ($toStatus === ...)が コントローラや複数ファイルに増殖していない?- 増えているなら、「ルールの置き場所」が決まっている?
✅ 混ざり(保存・通知・監査)
- 1つのメソッドに 保存(DB)・通知・監査ログ が全部混ざっていない?
- “成功した変更” で 監査ログが必ず残る 道になっている?
✅ 漏れ(監査・ログ)
- 例外ケースだけ監査が抜けるルートがない?(return/abort/throwの手前で抜けてない?)
-
ログに
request_id / actor_id / from_status / to_statusが揃っている?
11.2 エンジニア版
✅ 影響範囲(変更に強いか)
-
ルール変更は
transitionTo()周辺だけ見れば直せる? - 通知変更は Notifier だけで済む?
- 監査形式の変更は AuditLogger だけで済む?
✅ トランザクションと順番
- 保存と監査は 同じトランザクション で確定している?
- 通知は DB確定後 に送る構造になっている?(失敗時に通知だけ飛ばない?)
✅ テストしやすさ
- ルール部分が外部に依存せず、単体でテストできる?
- ユースケースのテストで、通知を安全に扱える?(本番に飛ばない工夫ができる構造?)
###11.3 迷ったときの判断基準(1行)
- 変更理由が違うものは分ける(ルール・保存・通知・監査を混ぜない)
12. 超要約(5行)
- OOPの目的は 変更の影響範囲を小さくすること
- ステータス変更は放置すると if増殖・直書き散在・混在 で壊れやすくなる
- まず 状態遷移の入口を一本化する(例:
transitionTo()) - ユースケースは 配線係として、保存・監査・通知を正しい順番でまとめる
- 分けすぎは逆効果。困っている場所から段階的にほどくのが現実解