Upgrade to Pro — share decks privately, control downloads, hide ads and more …

我々はなぜ「層」を分けるのか〜「関心の分離」と「抽象化」で手に入れる変更に強いシンプルな設計〜...

我々はなぜ「層」を分けるのか〜「関心の分離」と「抽象化」で手に入れる変更に強いシンプルな設計〜 #phperkaigi / PHPerKaigi 2026

2026年3月20-22日に開催された PHPerKaigi 2026 の登壇資料です。

Avatar for shogogg

shogogg

March 20, 2026
Tweet

More Decks by shogogg

Other Decks in Programming

Transcript

  1. 自己紹介 河瀨 翔吾 / Shogo Kawase        エンジニアリングマネージャー I LOVE...

    妻 / 型安全 / アジャイル / ももいろクローバーZ F1 / マリオカート / ACE COMBAT shogogg shogogg
  2. <?php $conn = mysql_connect('localhost', 'root', ''); mysql_select_db('shop', $conn); $id =

    mysql_escape_string($_GET['id']); $sql = "SELECT * FROM items WHERE id = {$id}"; $result = mysql_query($sql); echo '<ul>'; while ($row = mysql_fetch_assoc($result)) { echo "<li>{$row['name']} - ¥{$row['price']}</li>"; } echo '</ul>'; ?> いにしえの PHP
  3. MVC の流行 ① リクエスト Controller View Model ② 処理依頼 ③

    処理結果 User ④ パラメータ ⑤ 表示
  4. Mr. Logic Mr. Logic はそれでもいそがしい Assistant チャット Cloud Service B

    Cloud Service A Cloud Service C CSV JSON SQL 面倒だな…
  5. Mr. Logic 専門家がいれば…… Cloud Service B Cloud Service A Cloud

    Service C CSV SQL 楽になった! Sheet JSON Admin
  6. 関心の分離 Controller Infrastructure Cloud Service A CSV JSON SQL User

    チャット Email 郵送 FAX 電話 Cloud Service B Cloud Service C リクエストに基づく入出力 DB・API・外部モジュール・レガシーコードとのやり取り Service
  7. 問題のあるコード // Controller public function index(Request $request) { return $this->service->findUsers($request->context(),

    $request->all()); } // Service public function findUsers(Context $context, array $params) { // ロジック内で「 HTTP の都合(文字列)」を解釈させられている $isActive = isset($params['is_active']) && $params['is_active'] === 'true'; $role = UserRole::tryFrom((int)$params['role']); $dateFrom = isset($params['date_from']) ? Carbon::parse($params['date_from']) : null; // 本来の Service の責務:コンテキストに基づいて条件の絞り込み $tenantId = $context->isAdmin() ? ($params['tenant_id'] ?? null) : $context->tenantId(); return $this->userRepository->search($tenantId, $role, $dateFrom, $isActive); }
  8. 問題のあるコード // Controller public function index(Request $request) { return $this->service->findUsers($request->context(),

    $request->all()); } // Service public function findUsers(Context $context, array $params) { // ロジック内で「 HTTP の都合(文字列)」を解釈させられている $isActive = isset($params['is_active']) && $params['is_active'] === 'true'; $role = UserRole::tryFrom((int)$params['role']); $dateFrom = isset($params['date_from']) ? Carbon::parse($params['date_from']) : null; // 本来の Service の責務:コンテキストに基づいて条件の絞り込み $tenantId = $context->isAdmin() ? ($params['tenant_id'] ?? null) : $context->tenantId(); return $this->userRepository->search($tenantId, $role, $dateFrom, $isActive); } Controllerが入力値をServiceに丸投げ
  9. 問題のあるコード // Controller public function index(Request $request) { return $this->service->findUsers($request->context(),

    $request->all()); } // Service public function findUsers(Context $context, array $params) { // ロジック内で「 HTTP の都合(文字列)」を解釈させられている $isActive = isset($params['is_active']) && $params['is_active'] === 'true'; $role = UserRole::tryFrom((int)$params['role']); $dateFrom = isset($params['date_from']) ? Carbon::parse($params['date_from']) : null; // 本来の Service の責務:コンテキストに基づいて条件の絞り込み $tenantId = $context->isAdmin() ? ($params['tenant_id'] ?? null) : $context->tenantId(); return $this->userRepository->search($tenantId, $role, $dateFrom, $isActive); } Service 内で変換・判定が必要に……
  10. 問題のあるコード // Controller public function index(Request $request) { return $this->service->findUsers($request->context(),

    $request->all()); } // Service public function findUsers(Context $context, array $params) { // ロジック内で「 HTTP の都合(文字列)」を解釈させられている $isActive = isset($params['is_active']) && $params['is_active'] === 'true'; $role = UserRole::tryFrom((int)$params['role']); $dateFrom = isset($params['date_from']) ? Carbon::parse($params['date_from']) : null; // 本来の Service の責務:コンテキストに基づいて条件の絞り込み $tenantId = $context->isAdmin() ? ($params['tenant_id'] ?? null) : $context->tenantId(); return $this->userRepository->search($tenantId, $role, $dateFrom, $isActive); } 本来やりたいことはこれだけ
  11. 改善版 // Controller public function index(FindUsersRequest $request) { return $this->service->findUsers(

    $request->context(), $request->toCriteria(), ); } // Service public function findUsers(Context $context, UserSearchCriteria $criteria) { $tenantId = $context->isAdmin() ? $criteria->targetTenantId : $context->tenantId(); return $this->userRepository->search($tenantId, $criteria); }
  12. 改善版 // Controller public function index(FindUsersRequest $request) { return $this->service->findUsers(

    $request->context(), $request->toCriteria(), ); } // Service public function findUsers(Context $context, UserSearchCriteria $criteria) { $tenantId = $context->isAdmin() ? $criteria->targetTenantId : $context->tenantId(); return $this->userRepository->search($tenantId, $criteria); } Controller が Service の期待する形に入力値を変換して渡す
  13. 改善版 // Controller public function index(FindUsersRequest $request) { return $this->service->findUsers(

    $request->context(), $request->toCriteria(), ); } // Service public function findUsers(Context $context, UserSearchCriteria $criteria) { $tenantId = $context->isAdmin() ? $criteria->targetTenantId : $context->tenantId(); return $this->userRepository->search($tenantId, $criteria); } Service が本来の責務に集中できるようになった!!
  14. 問題が起きる例 // DB Migration Schema::table('members', function (Blueprint $table): void {

    // 社内管理用メモ $table->string('internal_note'); }); // ユーザー向け API 用のサービス public function show(int $id): ?Member { return Member::find($id); } // API のレスポンス { "id": 1, "name": "John Smith", "internal_note": "クレーマーなので要注意 " }
  15. 問題が起きる例 // DB Migration Schema::table('members', function (Blueprint $table): void {

    // 社内管理用メモ $table->string('internal_note'); }); // ユーザー向け API 用のサービス public function show(int $id): ?Member { return Member::find($id); } // API のレスポンス { "id": 1, "name": "John Smith", "internal_note": "クレーマーなので要注意 " } 社内用のメモ欄を記録するためのカラムを追加
  16. 問題が起きる例 // DB Migration Schema::table('members', function (Blueprint $table): void {

    // 社内管理用メモ $table->string('internal_note'); }); // ユーザー向け API 用のサービス public function show(int $id): ?Member { return Member::find($id); } // API のレスポンス { "id": 1, "name": "John Smith", "internal_note": "クレーマーなので要注意 " } ユーザー向け API の処理は一切変更していない
  17. 問題が起きる例 // DB Migration Schema::table('members', function (Blueprint $table): void {

    // 社内管理用メモ $table->string('internal_note'); }); // ユーザー向け API 用のサービス public function show(int $id): ?Member { return Member::find($id); } // API のレスポンス { "id": 1, "name": "John Smith", "internal_note": "クレーマーなので要注意 " } ユーザー向け API のレスポンスに社内管理用のメモが流出 😱
  18. 問題のあるコード // Repository public function lookup(int $id): ?Member { return

    EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { return $this->repository->lookup($id); }
  19. 問題のあるコード // Repository public function lookup(int $id): ?Member { return

    EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { return $this->repository->lookup($id); } Eloquent を変換して返していてバッチリ?
  20. 問題のあるコード // Repository public function lookup(int $id): ?Member { return

    EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { return $this->repository->lookup($id); } DB操作に失敗すると QueryException が throw されてしまう
  21. 問題のあるコード // Repository public function lookup(int $id): ?Member { return

    EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { return $this->repository->lookup($id); } 例外をキャッチしていないので伝播してしまう
  22. 改善版? // Repository public function lookup(int $id): ?Member { return

    EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { try { $member = $this->repository->lookup($id); } catch (QueryException $e) { $this->logger->error('Database query failed', ['exception' => $e]); } return $member; }
  23. 改善版? // Repository public function lookup(int $id): ?Member { return

    EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { try { $member = $this->repository->lookup($id); } catch (QueryException $e) { $this->logger->error('Database query failed', ['exception' => $e]); } return $member; } 例外をキャッチしてログを出力するように
  24. 改善版? // Repository public function lookup(int $id): ?Member { return

    EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { try { $member = $this->repository->lookup($id); } catch (QueryException $e) { $this->logger->error('Database query failed', ['exception' => $e]); } return $member; } データーベースに関する技術的詳細が層を超えて登場
  25. 今度こそ改善版 // Custom exception for better error handling final class

    RepositoryException extends \RuntimeException {} // Repository public function lookup(int $id): ?Member { try { return EloquentMember::find($id)?->toDomainModel(); } catch (QueryException $e) { throw new RepositoryException('Failed to lookup the member from database', 0, $e); } } // Service public function lookup(int $id): ?Member { try { $member = $this->repository->lookup($id); } catch (RepositoryException $e) { $this->logger->error('An error occurred while looking up the member', ['exception' => $e]); } return $member; }
  26. 今度こそ改善版 // Custom exception for better error handling final class

    RepositoryException extends \RuntimeException {} // Repository public function lookup(int $id): ?Member { try { return EloquentMember::find($id)?->toDomainMember(); } catch (QueryException $e) { throw new RepositoryException('Failed to lookup the member from database', 0, $e); } } // Service public function lookup(int $id): ?Member { try { $member = $this->repository->lookup($id); } catch (RepositoryException $e) { $this->logger->error('An error occurred while looking up the member', ['exception' => $e]); } return $member; } 技術的詳細と関係のない独自例外を定義する
  27. 今度こそ改善版 // Custom exception for better error handling final class

    RepositoryException extends \RuntimeException {} // Repository public function lookup(int $id): ?Member { try { return EloquentMember::find($id)?->toDomainModel(); } catch (QueryException $e) { throw new RepositoryException('Failed to lookup the member from database', 0, $e); } } // Service public function lookup(int $id): ?Member { try { $member = $this->repository->lookup($id); } catch (RepositoryException $e) { $this->logger->error('An error occurred while looking up the member', ['exception' => $e]); } return $member; } 発生した例外を独自例外でラップする
  28. 今度こそ改善版 // Custom exception for better error handling final class

    RepositoryException extends \RuntimeException {} // Repository public function lookup(int $id): ?Member { try { return EloquentMember::find($id)?->toDomain(); } catch (QueryException $e) { throw new RepositoryException('Failed to lookup the member from database', 0, $e); } } // Service public function lookup(int $id): ?Member { try { $member = $this->repository->lookup($id); } catch (RepositoryException $e) { $this->logger->error('An error occurred while looking up the member', ['exception' => $e]); } return $member; } サービス層に漏れ出ていた技術的詳細が消えた!
  29. まとめ ロジックの複雑さに立ち向かう • バックエンドが複雑なプレゼンテーションを担うことは減った一方、扱う技術やドメイ ンの複雑さは増している • 「層」を分離することで、我々は複雑さに立ち向かっている 「層」によって関心を分離する • ビジネスロジックを技術的な詳細から解放することでシンプルになり、変更に強くなる

    • 内側の層(サービス)の都合に外側の層(コントローラーやインフラ)が合わせる 適切な「抽象化」が分離の鍵 • 抽象化が甘すぎると、サービス層に技術的な詳細が浸食しやすくなる • 関心の分離は、適切な抽象化によって達成される