1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【ちょっとここいらで一つまみ】オブジェクト指向編(導入編)

1
Posted at

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:英語で統一)

  • DRAFT
  • SUBMITTED
  • APPROVED
  • REJECTED
  • CANCELED

よくある状態遷移(例)

  • DRAFTSUBMITTED(提出)
  • SUBMITTEDAPPROVED(承認)
  • SUBMITTEDREJECTED(却下)
  • APPROVEDCANCELED(キャンセル)
  • REJECTEDSUBMITTED(修正して再提出)

例外ルール(増えがちなやつ)

ステータス変更は、だいたいこういう条件が後から追加されます。

  • 権限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つだけ)

  1. 遷移できるか判断する(ルール)
  2. OKならステータスを更新する(状態の更新)

重要:保存(DB)や通知、監査ログはここではやりません。
transitionTo() は「ルールと状態遷移」に集中させます。


7.2 例:RequestModeltransitionTo() を用意する

以下は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つです。

  1. 取得:対象を取得する(RepositoryやModel取得)
  2. 遷移transitionTo() を呼ぶ(ルール判定+状態更新)
  3. 周辺処理:保存・監査・通知を確実に実行する

逆に、ユースケースでやらないことも決めておきます。

  • ルールの詳細(→モデル側)
  • 通知の送り方の詳細(→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_status
  • to_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_id
  • actor_id
  • from_status
  • to_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 ここまでの落としどころ(最小セット)

迷ったら、まずはこの最小セットだけで十分です。

  1. 状態遷移の入口を一本化transitionTo()
  2. 成功時に必ず監査が残る道を作る(ユースケースで記録)
  3. 保存・通知・監査を混ぜない(役割で分ける)

この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()
  • ユースケースは 配線係として、保存・監査・通知を正しい順番でまとめる
  • 分けすぎは逆効果。困っている場所から段階的にほどくのが現実解
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?