clean-architecture-php について
PHP 8.3+ と Symfony 7.x 向けに、クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ、ドメイン駆動設計を扱うスキルです。ドメインロジックをフレームワークやインフラから分離し、コア層を独立してテストできる構成を案内します。
主な機能
- 01依存関係を内側へ向けるレイヤー設計
- 02ドメイン駆動設計パターンの適用
- 03ポートとアダプターによるインフラ分離
- 04インメモリリポジトリによるテスト支援
- 05Symfony の依存性注入の設定
活用シーン
- 01新しい Symfony のエンタープライズアプリを設計
- 02レガシー PHP をテスト可能な構造にリファクタリング
- 03PHP プロジェクトにドメイン駆動設計を導入
- 04交換可能なインフラを持つモジュラーシステムを構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ giuseppe-trisciuoglio/ developer-kit --skill clean-architecture-phpインストール後、Claude Code で/clean-architecture-phpを入力して読み込まれたか確認します。
同梱ファイルreferences/php-clean-architecture.mdreferences/symfony-implementation.md
詳細
PHP/Symfony 向けクリーンアーキテクチャ、ヘキサゴナルアーキテクチャ、DDD
概要
このスキルでは、Symfony 7.x を使用する PHP 8.3 以降のアプリケーションで、クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ(ポート&アダプター)、ドメイン駆動設計のパターンを実装するためのガイダンスを提供します。内側にのみ依存するレイヤー構造を通じて、関心事を明確に分離し、フレームワークに依存しないビジネスロジックと、テストしやすいコードを実現します。
使用する場面
- Symfony 7.x で新しいエンタープライズ向け PHP アプリケーションを設計する場合
- レガシー PHP コードを、最新のテストしやすいパターンにリファクタリングする場合
- PHP プロジェクトでドメイン駆動設計を実装する場合
- 関心事が明確に分離された、保守しやすいアプリケーションを作成する場合
- フレームワークに依存しない、テスト可能なビジネスロジックを構築する場合
- インフラストラクチャを差し替え可能な、モジュール式の PHP システムを設計する場合
手順
1. アーキテクチャのレイヤーを理解する
クリーンアーキテクチャは依存関係のルールに従います。依存関係は内側にのみ向かいます。
+-------------------------------------+
| Infrastructure (Frameworks) | Symfony, Doctrine, External APIs
+-------------------------------------+
| Adapter (Interface Adapters) | Controllers, Repositories, Presenters
+-------------------------------------+
| Application (Use Cases) | Commands, Handlers, DTOs
+-------------------------------------+
| Domain (Entities & Business Rules) | Entities, Value Objects, Domain Events
+-------------------------------------+
ヘキサゴナルアーキテクチャ(ポート&アダプター):
- ドメインコア:ビジネスロジック。フレームワークに依存しません
- ポート:インターフェース(例:
UserRepositoryInterface) - アダプター:具体的な実装(Doctrine、テスト用の InMemory など)
DDD の戦術的パターン:
- エンティティ:ID を持つオブジェクト(例:
User、Order) - 値オブジェクト:属性によって定義される不変のオブジェクト(例:
Email、Money) - 集約:ルートエンティティを持つ整合性の境界
- ドメインイベント:ビジネス上の出来事を記録します
- リポジトリ:集約を永続化および取得します
2. ディレクトリ構造を整理する
レイヤーの分離を徹底するため、次のディレクトリ構造を作成します。
src/
+-- Domain/ # Innermost layer - no dependencies
| +-- Entity/
| | +-- User.php
| | +-- Order.php
| +-- ValueObject/
| | +-- Email.php
| | +-- Money.php
| | +-- OrderId.php
| +-- Repository/
| | +-- UserRepositoryInterface.php
| +-- Event/
| | +-- UserCreatedEvent.php
| +-- Exception/
| +-- DomainException.php
+-- Application/ # Use cases - depends on Domain
| +-- Command/
| | +-- CreateUserCommand.php
| | +-- UpdateOrderCommand.php
| +-- Handler/
| | +-- CreateUserHandler.php
| | +-- UpdateOrderHandler.php
| +-- Query/
| | +-- GetUserQuery.php
| +-- Dto/
| | +-- UserDto.php
| +-- Service/
| +-- NotificationServiceInterface.php
+-- Adapter/ # Interface adapters
| +-- Http/
| | +-- Controller/
| | | +-- UserController.php
| | +-- Request/
| | +-- CreateUserRequest.php
| +-- Persistence/
| +-- Doctrine/
| +-- Repository/
| | +-- DoctrineUserRepository.php
| +-- Mapping/
| +-- User.orm.xml
+-- Infrastructure/ # Framework & external concerns
+-- Config/
| +-- services.yaml
+-- Event/
| +-- SymfonyEventDispatcher.php
+-- Service/
+-- SendgridEmailService.php
3. ドメインレイヤーを実装する
最も内側のレイヤー(ドメイン)から始め、外側へと進めます。
- 値オブジェクトを作成する際に、コンストラクターで検証を行います。PHP 8.1 以降の
readonlyを使って不変にする必要があります - ドメインロジックとビジネスルールを備えたエンティティを作成します。エンティティは単なるデータの入れ物ではなく、振る舞いをカプセル化する必要があります
- リポジトリインターフェース(ポート)を定義します。小さく、目的を絞ったものにします
- コアのビジネスロジックから副作用を切り離すために、ドメインイベントを定義します
4. アプリケーションレイヤーを実装する
ドメインオブジェクトを調整するユースケースを構築します。
- 書き込み操作を表す readonly DTO として、コマンドを作成します
- 読み取り操作用のクエリを作成します(CQRS パターン)
- コマンドやクエリを受け取り、ドメインオブジェクトを調整するハンドラーを実装します
- 外部依存関係(通知など)のサービスインターフェースを定義します
5. アダプターレイヤーを実装する
アプリケーションとインフラストラクチャを接続するインターフェースアダプターを作成します。
- HTTP リクエストを受け取り、ハンドラーを呼び出すコントローラーを作成します
- Symfony のバリデーション属性を持つリクエスト DTO を作成します
- ドメインインターフェースと永続化レイヤーをつなぐリポジトリーアダプターを実装します
6. インフラストラクチャを設定する
フレームワーク固有の設定を行います。
- インターフェースと実装を対応付けるために、Symfony DI を設定します
- データベースなしでユニットテストを行えるよう、テスト用の代替実装(In-Memory リポジトリ)を作成します
- 永続化のためのDoctrine マッピングを設定します
7. フレームワークなしでテストする
Symfony、Doctrine、データベースなしで、ドメインレイヤーとアプリケーションレイヤーをテストできるようにします。高速なユニットテストには In-Memory リポジトリを使用します。
例
例 1:バリデーション付きの値オブジェクト
<?php
// src/Domain/ValueObject/Email.php
namespace App\Domain\ValueObject;
use InvalidArgumentException;
final readonly class Email
{
public function __construct(
private string $value
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException(
sprintf('"%s" is not a valid email address', $value)
);
}
}
public function value(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function domain(): string
{
return substr($this->value, strrpos($this->value, '@') + 1);
}
}
例 2:ドメインロジックを持つエンティティ
<?php
// src/Domain/Entity/User.php
namespace App\Domain\Entity;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use App\Domain\Event\UserCreatedEvent;
use DateTimeImmutable;
class User
{
private array $domainEvents = [];
public function __construct(
private UserId $id,
private Email $email,
private string $name,
private DateTimeImmutable $createdAt,
private bool $isActive = true
) {
$this->recordEvent(new UserCreatedEvent($id->value()));
}
public static function create(
UserId $id,
Email $email,
string $name
): self {
return new self(
$id,
$email,
$name,
new DateTimeImmutable()
);
}
public function deactivate(): void
{
$this->isActive = false;
}
public function canPlaceOrder(): bool
{
return $this->isActive;
}
public function id(): UserId
{
return $this->id;
}
public function email(): Email
{
return $this->email;
}
public function domainEvents(): array
{
return $this->domainEvents;
}
public function clearDomainEvents(): void
{
$this->domainEvents = [];
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}
例 3:リポジトリポート(インターフェース)
<?php
// src/Domain/Repository/UserRepositoryInterface.php
namespace App\Domain\Repository;
use App\Domain\Entity\User;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
interface UserRepositoryInterface
{
public function findById(UserId $id): ?User;
public function findByEmail(Email $email): ?User;
public function save(User $user): void;
public function delete(UserId $id): void;
}
例 4:コマンドとハンドラー
<?php
// src/Application/Command/CreateUserCommand.php
namespace App\Application\Command;
final readonly class CreateUserCommand
{
public function __construct(
public string $id,
public string $email,
public string $name
) {
}
}
<?php
// src/Application/Handler/CreateUserHandler.php
namespace App\Application\Handler;
use App\Application\Command\CreateUserCommand;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use InvalidArgumentException;
readonly class CreateUserHandler
{
public function __construct(
private UserRepositoryInterface $userRepository
) {
}
public function __invoke(CreateUserCommand $command): void
{
$email = new Email($command->email);
if ($this->userRepository->findByEmail($email) !== null) {
throw new InvalidArgumentException(
'User with this email already exists'
);
}
$user = User::create(
new UserId($command->id),
$email,
$command->name
);
$this->userRepository->save($user);
}
}
例 5:Symfony コントローラー
<?php
// src/Adapter/Http/Controller/UserController.php
namespace App\Adapter\Http\Controller;
use App\Adapter\Http\Request\CreateUserRequest;
use App\Application\Command\CreateUserCommand;
use App\Application\Handler\CreateUserHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[AsController]
class UserController
{
public function __construct(
private CreateUserHandler $createUserHandler
) {
}
#[Route('/api/users', methods: ['POST'])]
public function create(CreateUserRequest $request): JsonResponse
{
$command = new CreateUserCommand(
id: Uuid::v4()->toRfc4122(),
email: $request->email,
name: $request->name
);
($this->createUserHandler)($command);
return new JsonResponse(['id' => $command->id], 201);
}
}
例 6:バリデーション付きリクエスト DTO
<?php
// src/Adapter/Http/Request/CreateUserRequest.php
namespace App\Adapter\Http\Request;
use Symfony\Component\Validator\Constraints as Assert;
class CreateUserRequest
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
public string $name;
}
例 7:Doctrine リポジトリアダプター
<?php
// src/Adapter/Persistence/Doctrine/Repository/DoctrineUserRepository.php
namespace App\Adapter\Persistence\Doctrine\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {
}
public function findById(UserId $id): ?User
{
return $this->entityManager
->getRepository(User::class)
->find($id->value());
}
public function findByEmail(Email $email): ?User
{
return $this->entityManager
->getRepository(User::class)
->findOneBy(['email.value' => $email->value()]);
}
public function save(User $user): void
{
$this->entityManager->persist($user);
$this->entityManager->flush();
}
public function delete(UserId $id): void
{
$user = $this->findById($id);
if ($user !== null) {
$this->entityManager->remove($user);
$this->entityManager->flush();
}
}
}
例 8:Symfony DI の設定
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Domain/Entity/'
- '../src/Kernel.php'
# Repository binding - Port to Adapter
App\Domain\Repository\UserRepositoryInterface:
class: App\Adapter\Persistence\Doctrine\Repository\DoctrineUserRepository
# In-memory repository for tests
App\Domain\Repository\UserRepositoryInterface $inMemoryUserRepository:
class: App\Tests\Infrastructure\Repository\InMemoryUserRepository
例 9:テスト用の In-Memory リポジトリ
<?php
// tests/Infrastructure/Repository/InMemoryUserRepository.php
namespace App\Tests\Infrastructure\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
class InMemoryUserRepository implements UserRepositoryInterface
{
private array $users = [];
public function findById(UserId $id): ?User
{
return $this->users[$id->value()] ?? null;
}
public function findByEmail(Email $email): ?User
{
foreach ($this->users as $user) {
if ($user->email()->equals($email)) {
return $user;
}
}
return null;
}
public function save(User $user): void
{
$this->users[$user->id()->value()] = $user;
}
public function delete(UserId $id): void
{
unset($this->users[$id->value()]);
}
}
ベストプラクティス
- 依存関係のルール:依存関係は内側にのみ向けます。ドメインはアプリケーションやインフラストラクチャについて何も知りません。
- 不変性:値オブジェクトは、PHP 8.1 以降では
readonlyを使用して必ず不変にします。可変状態は決して許可しないでください。 - リッチドメインモデル:
create()のようなファクトリーメソッドを備えたエンティティにビジネスロジックを配置します。貧血モデルは避けてください。 - インターフェース分離:リポジトリインターフェースは小さく、目的を絞ったものにします。何でも含む巨大なインターフェースは作成しないでください。
- フレームワークからの独立性:ドメイン層とアプリケーション層は、Symfony や Doctrine がなくてもテスト可能でなければなりません。
- 構築時のバリデーション:値オブジェクトの構築時にバリデーションを行います。無効な状態を決して許可しないでください。
- Symfony 属性:ルーティング(
#[Route])、バリデーション(#[Assert\])、DI には PHP 8 の属性を使用します。 - テストダブル:高速な単体テストを可能にするため、リポジトリには必ずインメモリー実装を用意します。
- ドメインイベント:副作用を切り離すためにドメインイベントをディスパッチします。エンティティから外部サービスを呼び出さないでください。
- XML/YAML マッピング:ドメインエンティティのアノテーションではなく、Doctrine のマッピングには XML または YAML を使用します。
制約と警告
アーキテクチャ上の制約
- 依存関係のルール:依存関係は内側にのみ向けます。ドメインはアプリケーションについて何も知らず、アプリケーションはインフラストラクチャについて何も知りません。これに違反すると、アーキテクチャが損なわれます。
- 貧血ドメインの禁止:エンティティは単なるデータの入れ物ではなく、振る舞いをカプセル化する必要があります。ビジネスロジックを伴わないゲッターやセッターは避けてください。
- インターフェース分離:リポジトリインターフェースは小さく、目的を絞ったものにします。何でも含む巨大なインターフェースは作成しないでください。
PHP の実装上の制約
- 不変性:値オブジェクトは、PHP 8.1 以降では
readonlyを使用して必ず不変にします。値オブジェクトで可変状態を決して許可しないでください。 - バリデーション:値オブジェクトの構築時にバリデーションを行います。無効な状態が存在しないようにしてください。
- Symfony 属性:ルーティング、バリデーション、DI には PHP 8 の属性(
#[Route]、#[Assert\Email]、#[Autowire])を使用します。
テスト上の制約
- フレームワークからの独立性:ドメイン層とアプリケーション層は、Symfony、Doctrine、データベースがなくてもテスト可能でなければなりません。
- テストダブル:高速な単体テストを可能にするため、リポジトリインターフェースには必ずインメモリー実装を用意します。
警告
- コントローラーでのリッチドメインモデルの回避:コントローラーは調整のみを行い、ビジネスロジックを含めないでください。
- 抽象化の漏れに注意:Doctrine のアノテーションなどのインフラストラクチャ上の関心事を、ドメインエンティティに漏らさないでください。代わりに XML/YAML マッピングを使用します。
- コマンドバスの検討:複雑なアプリケーションでは、非同期処理に Symfony Messenger を使用します。ハンドラーに複雑なオーケストレーションをインラインで記述しないでください。
- ドメインイベント:コアのビジネスロジックから副作用を切り離すために、ドメインイベントをディスパッチします。エンティティから外部サービスを直接呼び出さないでください。