Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

自己紹介 河瀨 翔吾 / Shogo Kawase        エンジニアリングマネージャー I LOVE... 妻 / 型安全 / アジャイル / ももいろクローバーZ F1 / マリオカート / ACE COMBAT shogogg shogogg

Slide 3

Slide 3 text

おしながき 1 「層」についてのおさらい 2 「層」が実現する関心の分離と抽象化 3 「層」を形骸化させるアンチパターン 4 まとめ

Slide 4

Slide 4 text

おしながき 1 「層」についてのおさらい 2 「層」が実現する関心の分離と抽象化 3 「層」を形骸化させるアンチパターン 4 まとめ

Slide 5

Slide 5 text

いろいろな「層」の分け方 ● MVC ● 3層アーキテクチャ ● オニオンアーキテクチャ ● クリーンアーキテクチャ(?)

Slide 6

Slide 6 text

'; while ($row = mysql_fetch_assoc($result)) { echo "
  • {$row['name']} - ¥{$row['price']}
  • "; } echo ''; ?> いにしえの PHP

    Slide 7

    Slide 7 text

    MVC の流行 ① リクエスト Controller View Model ② 処理依頼 ③ 処理結果 User ④ パラメータ ⑤ 表示

    Slide 8

    Slide 8 text

    The Clean Architecture 引用元:Robert C. Martin「The Clean Architecture」

    Slide 9

    Slide 9 text

    おしながき 1 「層」についてのおさらい 2 「層」が実現する関心の分離と抽象化 3 「層」を形骸化させるアンチパターン 4 まとめ

    Slide 10

    Slide 10 text

    Mr. Logic Mr. Logic

    Slide 11

    Slide 11 text

    Mr. Logic Mr. Logic はとってもいそがしい 顧客 いそがしい…… チャット Email 郵送 FAX 電話

    Slide 12

    Slide 12 text

    アシスタントがいれば…… 顧客 Assistant Mr. Logic 楽になった! チャット Email 郵送 FAX 電話 チャット

    Slide 13

    Slide 13 text

    Mr. Logic Mr. Logic はそれでもいそがしい Assistant チャット Cloud Service B Cloud Service A Cloud Service C CSV JSON SQL 面倒だな…

    Slide 14

    Slide 14 text

    Mr. Logic 専門家がいれば…… Cloud Service B Cloud Service A Cloud Service C CSV SQL 楽になった! Sheet JSON Admin

    Slide 15

    Slide 15 text

    Mr. Logic こうして Mr. Logic は救われた Assistant チャット Sheet Admin

    Slide 16

    Slide 16 text

    関心の分離 Controller Infrastructure Cloud Service A CSV JSON SQL User チャット Email 郵送 FAX 電話 Cloud Service B Cloud Service C リクエストに基づく入出力 DB・API・外部モジュール・レガシーコードとのやり取り Service

    Slide 17

    Slide 17 text

    Service 抽象化? Controller Infrastructure ユーザーからJSONで受け取った データをDBに保存するよ!

    Slide 18

    Slide 18 text

    Service 抽象化できてない…… Controller Infrastructure ユーザーからJSONで受け取った データをDBに保存するよ!

    Slide 19

    Slide 19 text

    Service 抽象化すると? Controller Infrastructure どっかから受け取ったデータを なんらかの形で保存しておくよ!

    Slide 20

    Slide 20 text

    おしながき 1 「層」についてのおさらい 3 「層」を形骸化させるアンチパターン 4 まとめ 2 「層」が実現する関心の分離と抽象化

    Slide 21

    Slide 21 text

    アンチパターン① HTTPの都合に振り回されるサービス

    Slide 22

    Slide 22 text

    問題のあるコード // 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); }

    Slide 23

    Slide 23 text

    問題のあるコード // 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に丸投げ

    Slide 24

    Slide 24 text

    問題のあるコード // 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 内で変換・判定が必要に……

    Slide 25

    Slide 25 text

    問題のあるコード // 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); } 本来やりたいことはこれだけ

    Slide 26

    Slide 26 text

    改善版 // 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); }

    Slide 27

    Slide 27 text

    改善版 // 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 の期待する形に入力値を変換して渡す

    Slide 28

    Slide 28 text

    改善版 // 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 が本来の責務に集中できるようになった!!

    Slide 29

    Slide 29 text

    アンチパターン② 浸食する Active Record

    Slide 30

    Slide 30 text

    問題が起きる例 // 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": "クレーマーなので要注意 " }

    Slide 31

    Slide 31 text

    問題が起きる例 // 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": "クレーマーなので要注意 " } 社内用のメモ欄を記録するためのカラムを追加

    Slide 32

    Slide 32 text

    問題が起きる例 // 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 の処理は一切変更していない

    Slide 33

    Slide 33 text

    問題が起きる例 // 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 のレスポンスに社内管理用のメモが流出 😱

    Slide 34

    Slide 34 text

    Active Record(Eloquent)の利点と限界 ● Active Record は強力なデザインパターンであり、小規模なアプリケーションであれば 開発スピードに大きく寄与する ● 一方で、データベースと密結合していることで、変更が予期せぬ影響を及ぼしたり、ど こでもデータベース操作ができてしまうなど、責務の分離が難しくなる ● ある程度の規模になったら(またはそれを見込むのであれば)Active Record はインフ ラ層に閉じ込め、サービス層では独自に定義したモデル(DTO/POPO)に変換して扱 うのがオススメ

    Slide 35

    Slide 35 text

    アンチパターン③ 技術的詳細を漏らす例外

    Slide 36

    Slide 36 text

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

    Slide 37

    Slide 37 text

    問題のあるコード // 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 を変換して返していてバッチリ?

    Slide 38

    Slide 38 text

    問題のあるコード // 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 されてしまう

    Slide 39

    Slide 39 text

    問題のあるコード // Repository public function lookup(int $id): ?Member { return EloquentMember::find($id)?->toDomainModel(); } // Service public function lookup(int $id): ?Member { return $this->repository->lookup($id); } 例外をキャッチしていないので伝播してしまう

    Slide 40

    Slide 40 text

    改善版? // 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; }

    Slide 41

    Slide 41 text

    改善版? // 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; } 例外をキャッチしてログを出力するように

    Slide 42

    Slide 42 text

    改善版? // 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; } データーベースに関する技術的詳細が層を超えて登場

    Slide 43

    Slide 43 text

    今度こそ改善版 // 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; }

    Slide 44

    Slide 44 text

    今度こそ改善版 // 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; } 技術的詳細と関係のない独自例外を定義する

    Slide 45

    Slide 45 text

    今度こそ改善版 // 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; } 発生した例外を独自例外でラップする

    Slide 46

    Slide 46 text

    今度こそ改善版 // 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; } サービス層に漏れ出ていた技術的詳細が消えた!

    Slide 47

    Slide 47 text

    おしながき 1 「層」についてのおさらい 3 「層」を形骸化させるアンチパターン 4 まとめ 2 「層」が実現する関心の分離と抽象化

    Slide 48

    Slide 48 text

    まとめ ロジックの複雑さに立ち向かう ● バックエンドが複雑なプレゼンテーションを担うことは減った一方、扱う技術やドメイ ンの複雑さは増している ● 「層」を分離することで、我々は複雑さに立ち向かっている 「層」によって関心を分離する ● ビジネスロジックを技術的な詳細から解放することでシンプルになり、変更に強くなる ● 内側の層(サービス)の都合に外側の層(コントローラーやインフラ)が合わせる 適切な「抽象化」が分離の鍵 ● 抽象化が甘すぎると、サービス層に技術的な詳細が浸食しやすくなる ● 関心の分離は、適切な抽象化によって達成される

    Slide 49

    Slide 49 text

    \採用やってます!!/