Alexis Mabanza @ alexvolkihar.ovh

ヘキサゴナルアーキテクチャ入門:PHPでスパゲッティコードからクリーンなコードへ

Jul 6 · 15min

English Version · Version Française

スライド: SPA(フランス語ごのみ)

Slidev で作成さくせい - presentation slides for developers.

プロジェクトの最初さいしょの数すうヶ月げつ、アーキテクチャは大抵たいていの場合ばあい、締切しめきりに負まける。オールインワンのフレームワークを選えらび、機能きのうをリリースし、そのまま前まえに進すすむ。ツケは後あとになって回まわってくる――保守ほしゅコストは膨ふくらみ、リグレッションは積つみ重かさなり、ビジネスロジックはデータベースやサードパーティライブラリ、そしてフレームワーク自体じたいにがっちり溶接ようせつされてしまう。

ヘキサゴナルアーキテクチャ(ポート&アダプターとも呼よばれる)は、この問題もんだいへの一ひとつの答こたえだ。Alistair Cockburnが2005年ねんに提唱ていしょうしたもので、要ようはビジネスロジックがインフラの詳細しょうさいに一切いっさい触ふれないようにアプリケーションを構造化こうぞうかする、という考かんがえ方かただ。

以下いかでは、密結合みっけつごうしたコントローラーから出発しゅっぱつし、このパターンが実際じっさいに何なにを要求ようきゅうするのかを一ひとつずつ確認かくにんしながら、そのコントローラー層そうを段階的だんかいてきに作つくり直なおしていく。


1. 出発点しゅっぱつてん:密結合みっけつごうしたコード

ユーザー登録とうろくを処理しょりするPHPのコントローラーを見みてみよう。特とくに変かわったところはなく、おそらく皆みなさんが書かいたことがある、あるいは引ひき継ついだことのあるコードに近ちかいはずだ。

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

class RegistrationController extends Controller
{
    public function register(Request $request)
    {
        // 1. Direct HTTP validation
        $request->validate([
            'username' => 'required|string|max:255|unique:users',
            'email' => 'required|email|max:255|unique:users',
            'password' => 'required|string|min:8',
        ]);

        // 2. Business logic + Persistence (coupled Eloquent ORM)
        $user = new User();
        $user->username = $request->input('username');
        $user->email = $request->input('email');
        $user->password = password_hash($request->input('password'), PASSWORD_BCRYPT);
        $user->save(); // Direct coupling to the MySQL database via Active Record

        // 3. Email Notification (direct sending via SMTP with PHPMailer)
        $mail = new PHPMailer(true);
        try {
            // Hardcoded SMTP configuration or direct environment variables
            $mail->isSMTP();
            $mail->Host       = env('MAIL_HOST', 'smtp.mailtrap.io');
            $mail->SMTPAuth   = true;
            $mail->Username   = env('MAIL_USERNAME');
            $mail->Password   = env('MAIL_PASSWORD');
            $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
            $mail->Port       = env('MAIL_PORT', 587);

            $mail->setFrom('no-reply@notre-application.com', 'Mon App');
            $mail->addAddress($user->email, $user->username);

            $mail->isHTML(true);
            $mail->Subject = 'Bienvenue sur notre application !';
            $mail->Body    = "<h1>Bonjour {$user->username} !</h1><p>Merci de vous être inscrit.</p>";

            $mail->send();
        } catch (Exception $e) {
            // In case of email sending error, the HTTP response is compromised
            return response()->json(['error' => "Impossible d'envoyer l'email : {$mail->ErrorInfo}"], 500);
        }

        // 4. HTTP response
        return response()->json([
            'message' => 'Utilisateur créé avec succès !',
            'user' => [
                'id' => $user->id,
                'username' => $user->username,
                'email' => $user->email,
            ]
        ], 201);
    }
}

このコードが脆もろい理由りゆう

このコントローラーはちゃんと動うごく。入力にゅうりょくを検証けんしょうし、DBに保存ほぞんし、ウェルカムメールを送おくり、JSONを返かえす。問題もんだいが表面化ひょうめんかするのは、何なにかを変更へんこうしなければならなくなった日ひだ。

1. SOLIDを3点てんで破やぶっている

SRP。 RegistrationController は、HTTPのシリアライズ、入力にゅうりょくバリデーション、ビジネスルール(パスワードのハッシュ化か)、Eloquent経由けいゆのDBアクセス、SMTP設定せってい、レスポンスのフォーマットを一手いってに引ひき受うけている。このどれか一ひとつを変更へんこうするだけで、このクラスを編集へんしゅうすることになる。

OCP。 PHPMailerからMailgun、Brevo、AWS SESに乗のり換かえるには、クラスを開ひらいて中身なかみを書かき換かえる必要ひつようがある。ユーザー管理かんりがMySQLのテーブルから認証にんしょうマイクロサービスに移うつる場合ばあいも同おなじことが起おきる。

DIP。 「ユーザーを登録とうろくする」という高こうレベルの操作そうさが、MySQL用ようのEloquentやSMTP用ようのPHPMailerといった低ていレベルの詳細しょうさいに直接ちょくせつ依存いぞんしている。ビジネスコードはどちらの選択せんたくにも口出くちだしできない。

2. ユニットテストができない

ユーザー作成さくせいロジックを動うごかすには、本物ほんもののデータベース(あるいはEloquentのクエリをインターセプトする大量たいりょうのLaravelモック)に加くわえて、本物ほんもののSMTPサーバー、もしくはMailtrap、あるいはPHPMailerのグローバル変数へんすうを力技ちからわざでモックする仕組しくみが必要ひつようになる。

登録とうろくルールだけを1ミリ秒びょうで終おわるテストとして単独たんどくで実行じっこうする方法ほうほうはない。結局けっきょく書かくことになるテストは遅おそく、壊こわれやすい。

3. フレームワークに溶接ようせつされている

Request、Response、Eloquent、env()ヘルパー――このビジネスコードは事実上じじつじょうLaravelのコードだ。同おなじロジックをSymfonyやCLIコマンド、非同期ひどうきワーカーに移うつそうとしても、ほとんど何なにも生いき残のこらない。


2. ヘキサゴナルアーキテクチャとは何なにか

目的もくてきは、ビジネスコードをそれ以外いがいのすべてから切きり離はなすことにある。アプリケーションは閉とじたシステム、いわば「アプリケーションコア」になり、外そとの世界せかいとは自分自身じぶんじしんが定義ていぎした契約けいやくを通とおしてのみやり取とりする。

4つの構成要素こうせいようそ

プロジェクトは、ドメインとアプリケーションを中心ちゅうしんに配置はいちされた層そうに分割ぶんかつされる。

1. ドメイン

ヘキサゴンの中心ちゅうしん。エンティティ、値あたいオブジェクト、ドメインサービスがここに置おかれる。

  • ビジネスルールを保持ほじする。ユーザーは有効ゆうこうなメールアドレスを持もたなければならない、パスワードは一定いっていの強度きょうど基準きじゅんを満みたさなければならない、といった具合ぐあいだ。
  • 外部がいぶ依存いぞんを一切いっさい持もたない。フレームワーク、データベース、PHPMailer、HTTPについて何なにも知しらない。ただのPHPオブジェクト、それ以上いじょうでもそれ以下いかでもない。

2. アプリケーション層そう

制御せいぎょフローが実際じっさいに生いきる場所ばしょで、ユースケース(アプリケーションサービスと呼よぶ人ひともいる)として表現ひょうげんされる。

  • ユースケースとは、ユーザーや他ほかのシステムが実行じっこうできる一ひとつのアクションのことで、例たとえば RegisterUser がそれにあたる。
  • リクエストを受うけ取とり、ドメインエンティティを協調きょうちょうさせ、外そとの世界せかいにはインターフェース越ごしにしか触ふれない――DBへの保存ほぞん、メール送信そうしんなど。

3. ポート

ポートは境界線きょうかいせんそのものだ。コアが外部がいぶとどうやり取とりするかを定さだめるPHPの interface であり、2種類しゅるいに分わかれる。

インバウンドポート(ドライビングポートとも呼よばれる)は、外部がいぶがコア内ないの何なにかをどうやってトリガーできるかを示しめす。RegisterUserInterface はその一例いちれいだ。アウトバウンドポート(ドリブンポート)は、コアが処理しょりを完了かんりょうするために何なにを必要ひつようとしているかを示しめすが、どうやってそれを提供ていきょうするかは規定きていしない――ユーザーを保存ほぞんするための UserRepositoryInterface、メールを送おくるための MailerInterface などがそれにあたる。

4. アダプター

アダプターはヘキサゴンの外側そとがわ、インフラ層そうに存在そんざいし、あるテクノロジーとポートの間あいだを橋渡はしわたしする。

インバウンドアダプターは外部がいぶからの刺激しげきを受うけ取とり、それをインバウンドポートへの呼よび出だしに変換へんかんする――LaravelのHTTPコントローラー、Symfonyのコンソールコマンド、RabbitMQのコンシューマーなどだ。アウトバウンドアダプターはアウトバウンドポートを実装じっそうし、実際じっさいの技術的ぎじゅつてきな作業さぎょうを行おこなう――UserRepositoryInterface を実装じっそうする EloquentUserRepository、MailerInterface を実装じっそうする BrevoMailer、そしてテストのためだけに存在そんざいする InMemoryUserRepository などがそれにあたる。


依存性逆転いぞんせいぎゃくてんの原則げんそく

これらすべては、たった一ひとつの原則げんそくの上うえに成なり立たっている。

従来じゅうらいのレイヤードアーキテクチャでは、各層かくそうはその下したの層そうに依存いぞんする――コントローラー、次つぎにサービス、そしてORM経由けいゆのデータベース、という具合ぐあいだ。

ここでは、インフラがコアの内側うちがわで宣言せんげんされたインターフェースに依存いぞんする。これによって、実行じっこうフローの向むきと依存関係いぞんかんけいの向むきが切きり離はなされる。実行時じっこうじには、HTTPコントローラーがユースケースを呼よび出だし、ユースケースがデータベースアダプターを呼よび出だす。コード上じょうでは、データベースアダプターはアプリケーション層そうに存在そんざいする UserRepositoryInterface に依存いぞんしている。依存いぞんは内側うちがわを向むき、呼よび出だしは外側そとがわを向むく。

Important

ビジネスロジックを守まもっているのは、まさにこの依存性逆転いぞんせいぎゃくてんだ。ドメインとアプリケーションが必要ひつようとする契約けいやくを宣言せんげんし、インフラがそれを実装じっそうする。外側そとがわが内側うちがわに依存いぞんするのであって、その逆ぎゃくは決けっしてない。

この後あとの内容ないようは、この規則きそくに沿そってスパゲッティコントローラーをリファクタリングしていく。


3. コア:ドメインとポート

まずは中心ちゅうしんから始はじめよう。

ドメイン

ドメインはルールだけを保持ほじし、それ以外いがいは何なにも持もたない。フレームワークもデータベースもない素すのPHPで、不変条件ふへんじょうけん(インバリアント)が確実かくじつに守まもられるようにする責任せきにんを負おう。

1. ビジネス例外れいがい

まずは、技術的ぎじゅつてきなエラーではなく機能的きのうてきなエラーをモデル化かする例外れいがいから始はじめる。

<?php

namespace App\Domain\Exception;

class InvalidEmailException extends \DomainException
{
    public function __construct(string $email)
    {
        parent::__construct(sprintf('The email address "%s" is not valid.', $email));
    }
}
<?php

namespace App\Domain\Exception;

class WeakPasswordException extends \DomainException
{
    public function __construct()
    {
        parent::__construct('The password is too weak. It must contain at least 8 characters.');
    }
}

2. User エンティティ

このエンティティは不変条件ふへんじょうけんそのものを保有ほゆうする――有効ゆうこうなメールアドレス、十分じゅうぶんな強度きょうどのパスワード、そして値あたいが保存ほぞんされる前まえに必かならずハッシュ化かされること。

<?php

namespace App\Domain\Entity;

use App\Domain\Exception\InvalidEmailException;
use App\Domain\Exception\WeakPasswordException;

class User
{
    private string $id;
    private string $username;
    private string $email;
    private string $passwordHash;

    public function __construct(
        string $id,
        string $username,
        string $email,
        string $plainPassword
    ) {
        $this->id = $id;

        if (empty(trim($username))) {
            throw new \DomainException("Username cannot be empty.");
        }
        $this->username = $username;

        $this->setEmail($email);
        $this->setPassword($plainPassword);
    }

    public function getId(): string
    {
        return $this->id;
    }

    public function getUsername(): string
    {
        return $this->username;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function getPasswordHash(): string
    {
        return $this->passwordHash;
    }

    private function setEmail(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailException($email);
        }
        $this->email = $email;
    }

    private function setPassword(string $plainPassword): void
    {
        if (strlen($plainPassword) < 8) {
            throw new WeakPasswordException();
        }
        
        // Password hashing is an essential business security rule.
        $this->passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);
    }
}

ポート

ポートは、ヘキサゴンがそれを通とおしてやり取とりする契約けいやくだ。ドメインまたはユースケースが必要ひつようなものを宣言せんげんし、それがどう提供ていきょうされるかはどちらも知しらない。

1. UserRepositoryInterface、アウトバウンドポート

ヘキサゴンがユーザーを保存ほぞん・検索けんさくするために必要ひつようなものすべて。

<?php

namespace App\Domain\Repository;

use App\Domain\Entity\User;

interface UserRepositoryInterface
{
    public function save(User $user): void;
    public function findByEmail(string $email): ?User;
    public function existsByUsername(string $username): bool;
}

2. MailerInterface、アウトバウンドポート

そして、登録完了とうろくかんりょう後ごにユーザーへ通知つうちする能力のうりょく。

<?php

namespace App\Domain\Gateway;

use App\Domain\Entity\User;

interface MailerInterface
{
    public function sendWelcomeEmail(User $user): void;
}

4. アプリケーション層そう

この層そうはユースケースを調整ちょうせいする。依存いぞんするのはドメインとポートのみで、それ以外いがいには何なにも依存いぞんしない。

データ転送てんそうオブジェクト(DTO)

DTOは、構造化こうぞうかされた不変ふへんな形かたちでデータを出入でいりさせる役割やくわりを持もち、アプリケーションがHTTPリクエストやフレームワーク固有こゆうの型かたを直接ちょくせつ目めにすることはない。

1. RegisterUserRequest

<?php

namespace App\Application\DTO;

readonly class RegisterUserRequest
{
    public function __construct(
        public string $username,
        public string $email,
        public string $password
    ) {}
}

2. RegisterUserResponse

<?php

namespace App\Application\DTO;

use App\Domain\Entity\User;

readonly class RegisterUserResponse
{
    public function __construct(
        public string $id,
        public string $username,
        public string $email
    ) {}

    public static function fromEntity(User $user): self
    {
        return new self(
            $user->getId(),
            $user->getUsername(),
            $user->getEmail()
        );
    }
}

ユースケース:RegisterUser

ユーザー作成さくせいを統括とうかつするクラス。2つのポートはどちらもコンストラクタ経由けいゆで渡わたされる。

<?php

namespace App\Application\UseCase;

use App\Application\DTO\RegisterUserRequest;
use App\Application\DTO\RegisterUserResponse;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Gateway\MailerInterface;

class RegisterUser
{
    public function __construct(
        private UserRepositoryInterface $userRepository,
        private MailerInterface $mailer
    ) {}

    public function execute(RegisterUserRequest $request): RegisterUserResponse
    {
        // 1. Validation of uniqueness rules (requiring the UserRepository port)
        if ($this->userRepository->existsByUsername($request->username)) {
            throw new \DomainException("This username is already taken.");
        }

        if ($this->userRepository->findByEmail($request->email) !== null) {
            throw new \DomainException("This email address is already registered.");
        }

        // 2. Generation of a unique identifier (UUID-like)
        $id = bin2hex(random_bytes(16));

        // 3. Creation of the Domain entity (implicitly validating invariants)
        $user = new User(
            $id,
            $request->username,
            $request->email,
            $request->password
        );

        // 4. Persistence via the Port
        $this->userRepository->save($user);

        // 5. Sending the welcome email via the Port
        $this->mailer->sendWelcomeEmail($user);

        // 6. Return of the response DTO
        return RegisterUserResponse::fromEntity($user);
    }
}

Note

トランザクションの安全性あんぜんせいと副作用ふくさよう: この例れいでは、保存ほぞんの直後ちょくごにメールを送信そうしんしている。本番環境ほんばんかんきょうでSMTPに障害しょうがいが起おきると、ユーザーは既すでにDBに存在そんざいするにもかかわらず、ユースケース自体じたいは例外れいがいを投なげてしまう。よくある解決策かいけつさくは、ドメインイベントとアウトボックスパターンを組くみ合あわせ、メール送信そうしんを非同期ひどうきに切きり出だしてリトライ可能かのうにすることだ。


5. インフラストラクチャ層そう

インフラは、ポートの具体的ぐたいてきな実装じっそうと、コアを起動きどうするエントリーポイントを保持ほじする。

アウトバウンドアダプター

これらはアウトバウンドポートを、SQLやSMTPといった実際じっさいの技術ぎじゅつに対たいして実装じっそうする。

1. SqlUserRepository、PDO経由けいゆ

<?php

namespace App\Infrastructure\Adapter\Persistence;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use PDO;

class SqlUserRepository implements UserRepositoryInterface
{
    public function __construct(private PDO $pdo)
    {}

    public function save(User $user): void
    {
        $stmt = $this->pdo->prepare('
            INSERT INTO users (id, username, email, password_hash)
            VALUES (:id, :username, :email, :password_hash)
        ');

        $stmt->execute([
            'id' => $user->getId(),
            'username' => $user->getUsername(),
            'email' => $user->getEmail(),
            'password_hash' => $user->getPasswordHash(),
        ]);
    }

    public function findByEmail(string $email): ?User
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
        $stmt->execute(['email' => $email]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$row) {
            return null;
        }

        return $this->reconstituteEntity($row);
    }

    public function existsByUsername(string $username): bool
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM users WHERE username = :username');
        $stmt->execute(['username' => $username]);
        return (int) $stmt->fetchColumn() > 0;
    }

    /**
     * Reconstitutes a User entity from database data.
     * This method bypasses password hashing and validation of the plain password.
     */
    private function reconstituteEntity(array $row): User
    {
        $reflection = new \ReflectionClass(User::class);
        $user = $reflection->newInstanceWithoutConstructor();

        $properties = [
            'id' => $row['id'],
            'username' => $row['username'],
            'email' => $row['email'],
            'passwordHash' => $row['password_hash'],
        ];

        foreach ($properties as $name => $value) {
            $property = $reflection->getProperty($name);
            $property->setAccessible(true);
            $property->setValue($user, $value);
        }

        return $user;
    }
}

2. SmtpMailer、Symfony Mailer経由けいゆ

<?php

namespace App\Infrastructure\Adapter\Mailer;

use App\Domain\Entity\User;
use App\Domain\Gateway\MailerInterface;
use Symfony\Component\Mailer\MailerInterface as SymfonyMailerInterface;
use Symfony\Component\Mime\Email;

class SmtpMailer implements MailerInterface
{
    public function __construct(private SymfonyMailerInterface $symfonyMailer)
    {}

    public function sendWelcomeEmail(User $user): void
    {
        $email = (new Email())
            ->from('no-reply@our-application.com')
            ->to($user->getEmail())
            ->subject('Welcome to our application!')
            ->html(sprintf(
                '<h1>Hello %s!</h1><p>Thank you for registering.</p>',
                htmlspecialchars($user->getUsername(), ENT_QUOTES, 'UTF-8')
            ));

        $this->symfonyMailer->send($email);
    }
}

インバウンドアダプター

これらは外部がいぶからの刺激しげきを受うけ取とり、リクエストの形かたちを検証けんしょうしてからユースケースを呼よび出だす。

1. RegisterUserController

HTTPリクエストをデコードし、DTOを組くみ立たて、ユースケースを実行じっこうする。DomainException は 422 Unprocessable Entity として返かえされ、ドメイン自身じしんのメッセージがそのまま乗のる。

<?php

namespace App\Infrastructure\Adapter\Http;

use App\Application\DTO\RegisterUserRequest;
use App\Application\UseCase\RegisterUser;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class RegisterUserController
{
    public function __construct(private RegisterUser $registerUserUseCase)
    {}

    public function __invoke(ServerRequestInterface $request): ResponseInterface
    {
        $body = json_decode((string) $request->getBody(), true) ?? [];

        // 1. HTTP request validation
        if (empty($body['username']) || empty($body['email']) || empty($body['password'])) {
            return new Response(400, ['Content-Type' => 'application/json'], json_encode([
                'error' => 'The username, email, and password fields are required.'
            ]));
        }

        try {
            // 2. DTO creation
            $useCaseRequest = new RegisterUserRequest(
                username: $body['username'],
                email: $body['email'],
                password: $body['password']
            );

            // 3. Calling the use case
            $response = $this->registerUserUseCase->execute($useCaseRequest);

            // 4. Success response
            return new Response(201, ['Content-Type' => 'application/json'], json_encode([
                'message' => 'User created successfully!',
                'user' => [
                    'id' => $response->id,
                    'username' => $response->username,
                    'email' => $response->email,
                ]
            ]));
        } catch (\DomainException $e) {
            // Domain exceptions are translated into HTTP status code 422
            return new Response(422, ['Content-Type' => 'application/json'], json_encode([
                'error' => $e->getMessage()
            ]));
        } catch (\Throwable $e) {
            // Unforeseen technical exceptions are hidden (HTTP 500)
            return new Response(500, ['Content-Type' => 'application/json'], json_encode([
                'error' => 'An internal error occurred.'
            ]));
        }
    }
}

2. RegisterUserCommand

2つ目めのエントリーポイントとして、今度こんどはコンソールが同おなじユースケースに接続せつぞくされる。ビジネスコードは一行いちぎょうも変かわらない。

<?php

namespace App\Infrastructure\Adapter\Cli;

use App\Application\DTO\RegisterUserRequest;
use App\Application\UseCase\RegisterUser;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:register-user', description: 'Registers a new user.')]
class RegisterUserCommand extends Command
{
    public function __construct(private RegisterUser $registerUserUseCase)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('username', InputArgument::REQUIRED, 'The username')
            ->addArgument('email', InputArgument::REQUIRED, 'The email address')
            ->addArgument('password', InputArgument::REQUIRED, 'The password');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $username = $input->getArgument('username');
        $email = $input->getArgument('email');
        $password = $input->getArgument('password');

        try {
            $useCaseRequest = new RegisterUserRequest($username, $email, $password);
            $response = $this->registerUserUseCase->execute($useCaseRequest);

            $io->success(sprintf(
                'User created successfully! ID: %s, Name: %s, Email: %s',
                $response->id,
                $response->username,
                $response->email
            ));

            return Command::SUCCESS;
        } catch (\DomainException $e) {
            $io->error($e->getMessage());
            return Command::FAILURE;
        } catch (\Throwable $e) {
            $io->error('An unexpected error occurred: ' . $e->getMessage());
            return Command::INVALID;
        }
    }
}

6. ディレクトリ構成こうせいと配線はいせん

残のこるのは2つ――各層かくそうに対応たいおうしたディレクトリ構成こうせいと、どのアダプターがどのポートに応答おうとうするかを知しっているDIコンテナだ。

ディレクトリ構成こうせい

モダンなPHPアプリケーションにおいて、各層かくそうが src/ の中なかにどう配置はいちされるかを見みてみよう。

src/
├── Domain/
│   ├── Entity/
│   │   └── User.php
│   ├── ValueObject/
│   │   └── Email.php (optional)
│   ├── Exception/
│   │   ├── InvalidEmailException.php
│   │   └── WeakPasswordException.php
│   ├── Repository/         <-- アウトバウンドポート(Driven Ports)
│   │   └── UserRepositoryInterface.php
│   └── Gateway/            <-- サードパーティサービス向けのアウトバウンドポート
│       └── MailerInterface.php
├── Application/
│   ├── UseCase/            <-- ヘキサゴンのユースケース
│   │   └── RegisterUser.php
│   └── DTO/                <-- データ転送オブジェクト
│       ├── RegisterUserRequest.php
│       └── RegisterUserResponse.php
└── Infrastructure/
    ├── Adapter/            <-- 具体的なアダプター
    │   ├── Http/           <-- インバウンド(Driving):コントローラー
    │   │   └── RegisterUserController.php
    │   ├── Cli/            <-- インバウンド(Driving):コンソールコマンド
    │   │   └── RegisterUserCommand.php
    │   ├── Persistence/    <-- アウトバウンド(Driven):ORM、SQL、インメモリ
    │   │   ├── SqlUserRepository.php
    │   │   └── InMemoryUserRepository.php
    │   └── Mailer/         <-- アウトバウンド(Driven):SMTP、Brevoなど
    │       └── SmtpMailer.php
    └── Share/              <-- 共有コードと横断的なユーティリティ

この分離ぶんりは概念的がいねんてきなだけでなく、物理的ぶつりてきでもある。プロジェクトを初はじめて開ひらいた人ひとでも、一行いちぎょうのコードも読よまずに、ビジネスルールとオーケストレーションと技術的ぎじゅつてき詳細しょうさいを見分みわけられる。

配線はいせん

ヘキサゴンはインフラのクラスを直接ちょくせつインスタンス化かすることは決けっしてない。インターフェースに依存いぞんし、フレームワークのDIコンテナがそれを実行時じっこうじに解決かいけつする。

オプションA:Symfony(services.yaml)

Symfonyのオートワイヤリングは、クラス名めいが期待きたいされる型かたに一致いっちしていれば、ほとんどの部分ぶぶんを自動じどうでやってくれる。あるインターフェースに対たいして特定とくていのアダプターを選えらびたい場合ばあいは、明示的めいじてきにバインドする。

# config/services.yaml
services:
    # Default configuration
    _defaults:
        autowire: true      # Enables automatic injection
        autoconfigure: true # Automatically registers CLI commands, controllers, etc.

    # Make our application core and adapters available
    App\:
        resource: '../src/'
        exclude:
            - '../src/Domain/Entity/'
            - '../src/Domain/ValueObject/'
            - '../src/Domain/Exception/'
            - '../src/Application/DTO/'

    # Explicit binding of ports (interfaces) to adapters (implementations)
    App\Domain\Repository\UserRepositoryInterface:
        class: App\Infrastructure\Adapter\Persistence\SqlUserRepository

    App\Domain\Gateway\MailerInterface:
        class: App\Infrastructure\Adapter\Mailer\SmtpMailer

オプションB:Laravel(AppServiceProvider)

Laravelは同おなじバインディングをPHPで行おこない、サービスプロバイダー、通常つうじょうは register() の中なかに書かく。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\Adapter\Persistence\SqlUserRepository;
use App\Domain\Gateway\MailerInterface;
use App\Infrastructure\Adapter\Mailer\SmtpMailer;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register bindings in the container.
     */
    public function register(): void
    {
        // Bind interfaces (Ports) to concrete classes (Adapters)
        $this->app->bind(UserRepositoryInterface::class, SqlUserRepository::class);
        $this->app->bind(MailerInterface::class, SmtpMailer::class);
    }
}

7. さらに一歩いっぽ進すすめる

インフラなしでテストする

コアを疎結合そけつごうにすることで得えられる一番いちばんのメリットは、ユースケースをネットワークもファイルシステムもデータベースもなしにテストできることだ。

モックライブラリを使つかう手てもあるが、テストが冗長じょうちょうになり、内部ないぶをリファクタリングするたびに壊こわれやすい。ポートのインメモリ実装じっそうを書かく方ほうが、大抵たいていの場合ばあいは安上やすあがりだ。

1. InMemoryUserRepository

このテスト用ようアダプターは、エンティティをPHPの配列はいれつに保持ほじする。ユースケースから見みれば本物ほんもののデータベースのように振ふる舞まい、しかも用意よういするコストはゼロに等ひとしい。

<?php

namespace App\Infrastructure\Adapter\Persistence;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;

class InMemoryUserRepository implements UserRepositoryInterface
{
    /**
     * @var array<string, User>
     */
    private array $users = [];

    public function save(User $user): void
    {
        $this->users[$user->getId()] = $user;
    }

    public function findByEmail(string $email): ?User
    {
        foreach ($this->users as $user) {
            if ($user->getEmail() === $email) {
                return $user;
            }
        }
        return null;
    }

    public function existsByUsername(string $username): bool
    {
        foreach ($this->users as $user) {
            if ($user->getUsername() === $username) {
                return true;
            }
        }
        return false;
    }
}

メーラーについても同おなじ発想はっそうだ。InMemoryMailer は送信そうしんを依頼いらいされた内容ないようを記録きろくしておき、テストが後あとからそれを検証けんしょうできるようにする。

<?php

namespace App\Infrastructure\Adapter\Mailer;

use App\Domain\Entity\User;
use App\Domain\Gateway\MailerInterface;

class InMemoryMailer implements MailerInterface
{
    /**
     * @var array<int, User>
     */
    private array $sentEmails = [];

    public function sendWelcomeEmail(User $user): void
    {
        $this->sentEmails[] = $user;
    }

    public function hasSentWelcomeEmailTo(string $email): bool
    {
        foreach ($this->sentEmails as $user) {
            if ($user->getEmail() === $email) {
                return true;
            }
        }
        return false;
    }
}

2. PHPUnitのテスト

これで、テストはごく普通ふつうのユニットテストになる。テスト用ようデータベースは不要ふようだし、SMTPサーバーが落おちている朝あさにテストが失敗しっぱいすることもない。

<?php

namespace App\Tests\Application\UseCase;

use App\Application\DTO\RegisterUserRequest;
use App\Application\UseCase\RegisterUser;
use App\Domain\Exception\InvalidEmailException;
use App\Infrastructure\Adapter\Persistence\InMemoryUserRepository;
use App\Infrastructure\Adapter\Mailer\InMemoryMailer;
use PHPUnit\Framework\TestCase;

class RegisterUserTest extends TestCase
{
    private InMemoryUserRepository $userRepository;
    private InMemoryMailer $mailer;
    private RegisterUser $useCase;

    protected function setUp(): void
    {
        $this->userRepository = new InMemoryUserRepository();
        $this->mailer = new InMemoryMailer();
        
        // Direct instantiation of the use case with our in-memory adapters
        $this->useCase = new RegisterUser($this->userRepository, $this->mailer);
    }

    public function testUserRegistrationSuccess(): void
    {
        // Given
        $request = new RegisterUserRequest(
            username: 'alexdev',
            email: 'alex@example.com',
            password: 'SuperSecurePassword123'
        );

        // When
        $response = $this->useCase->execute($request);

        // Then
        $this->assertNotEmpty($response->id);
        $this->assertEquals('alexdev', $response->username);
        $this->assertEquals('alex@example.com', $response->email);

        // Verification of persistence in memory
        $savedUser = $this->userRepository->findByEmail('alex@example.com');
        $this->assertNotNull($savedUser);
        $this->assertEquals('alexdev', $savedUser->getUsername());

        // Verification of email delivery
        $this->assertTrue($this->mailer->hasSentWelcomeEmailTo('alex@example.com'));
    }

    public function testRegistrationFailsWithInvalidEmail(): void
    {
        // Given
        $request = new RegisterUserRequest(
            username: 'alexdev',
            email: 'invalid-email',
            password: 'SuperSecurePassword123'
        );

        // Then
        $this->expectException(InvalidEmailException::class);

        // When
        $this->useCase->execute($request);
    }

    public function testRegistrationFailsWithDuplicateEmail(): void
    {
        // Given - Registration of an existing user with this email
        $existingUser = new \App\Domain\Entity\User(
            'existing-uuid',
            'johndoe',
            'john@example.com',
            'Password12345'
        );
        $this->userRepository->save($existingUser);

        // Registration request with the same email
        $request = new RegisterUserRequest(
            username: 'newuser',
            email: 'john@example.com',
            password: 'SuperSecurePassword123'
        );

        // Then - Expecting double email exception
        $this->expectException(\DomainException::class);
        $this->expectExceptionMessage("This email address is already registered.");

        // When
        $this->useCase->execute($request);
    }
}

Tip

実行速度じっこうそくど: これらのテストは1つあたり2ミリ秒びょう以下いかで終おわる。数百すうひゃくのビジネスルールを持もつプロジェクトでも、数千すうせんのユニットテストが3秒びょう以内いないに完走かんそうする。これこそが、TDDを実践じっせん可能かのうにするフィードバックループだ。


Deptracでルールを強制きょうせいする

すべてはたった一ひとつのルールの上うえに成なり立たっている――内側うちがわの層そうは決けっして外側そとがわの層そうに依存いぞんしない、というルールだ。納期のうきのプレッシャーの下もとでは、誰だれかがDoctrineのクラスやHTTPコントローラーをドメインに直接ちょくせつimportしてしまい、コードレビューでもそれが見逃みのがされることがある。

Deptrac はこれを静的せいてきに検証けんしょうし、依存関係いぞんかんけいが誤あやまった方向ほうこうを向むいていればビルドを失敗しっぱいさせてくれる。上記じょうきの構成こうせいに対応たいおうする deptrac.yaml は以下いかの通とおり。

# deptrac.yaml
deptrac:
  paths:
    - src/
  layers:
    - name: Domain
      collectors:
        - type: directory
          value: src/Domain/.*
    - name: Application
      collectors:
        - type: directory
          value: src/Application/.*
    - name: Infrastructure
      collectors:
        - type: directory
          value: src/Infrastructure/.*
  ruleset:
    Domain:
      # The Domain is completely isolated: it depends on nothing else
      - ~
    Application:
      # The Application can only depend on the Domain
      - Domain
    Infrastructure:
      # The Infrastructure can depend on the Application and the Domain
      - Application
      - Domain

vendor/bin/deptrac を実行じっこうすればコードをスキャンし、間違まちがった向むきの依存関係いぞんかんけいがあれば大声おおごえでエラーを出だしてくれる。


DDDとCQRSはどこに位置いちづけられるか

ドメイン駆動設計くどうせっけい(DDD)

ヘキサゴンはDDDなしでも使つかえるが、両者りょうしゃは相性あいしょうがいい。DDDはビジネスを丁寧ていねいにモデリングすることが目的もくてきで、ヘキサゴンはそのモデルを技術的ぎじゅつてきなノイズから遠とおざけておく入いれ物ものだ。エンティティ、値あたいオブジェクト、集約しゅうやく、ドメインサービスはすべてドメイン層そうに置おかれ、DDDにおけるリポジトリは、まさにアウトバウンドポートそのものだ。

CQRS

CQRSは読よみ取とりと書かき込こみを分離ぶんりする。ヘキサゴンにおいて、書かき込こみのパスはユースケースを経由けいゆし、ドメインエンティティを操作そうさし、ポート経由けいゆで永続化えいぞくかする。

読よみ取とりのパスは、ヘキサゴンを迂回うかいしてもよいし、多おおくの場合ばあいそうすべきだ。クエリはビジネスルールを一切いっさい実行じっこうせず、データを射影しゃえいするだけだからだ。したがって、インバウンドアダプターは、完全かんぜんなエンティティを再構築さいこうちくしてから改あらためてフラット化かするのではなく、よくチューニングされた一本いっぽんのSQL文ぶんからビュー用ようのDTOを直接ちょくせつ返かえす専用せんようのクエリサービスを呼よび出だせばよい。


採用さいようすべきとき、そうでないとき

ここに銀ぎんの弾丸だんがんはない。ヘキサゴンは現実げんじつの何なにかを手てに入いれる代かわりに、現実げんじつのコストを払はらう。

得えられるもの:副作用ふくさようなしで動うごくユニットテスト、フレームワークやデータベース、サードパーティサービスを入いれ替かえる自由じゆう、技術的ぎじゅつてきなノイズに邪魔じゃまされずに読よめるビジネスロジック。ポートが事前じぜんに合意ごういされているため、あるチームがユースケースに取とり組くむ一方いっぽうで、別べつのチームがアダプターを書かく、という分業ぶんぎょうも可能かのうになる。

払はらうコスト:クラス、インターフェース、DTO、マッピングの数かずが大幅おおはばに増ふえる。チーム全員ぜんいんが依存性逆転いぞんせいぎゃくてんを本当ほんとうに理解りかいしている必要ひつようがある。そしてコードを追おうには、具体的ぐたいてきな実装じっそうにたどり着つく前まえに必かならずインターフェースを一枚いちまい通とおり抜ぬけなければならない。

実際じっさいに本物ほんもののビジネスロジックを持もつ中規模ちゅうきぼから大規模だいきぼのプロジェクト、下回したまわるインフラがバージョンやベンダーを変かえながら何年なんねんも動うごき続つづけることを前提ぜんていとしたアプリケーション、そしてテスト戦略せんりゃくが重要じゅうような意味いみを持もつ場面ばめんでは、それに見合みあう価値かちがある。

アプリケーションが純粋じゅんすいなCRUDであれば、見送みおくっていい。ルールを適用てきようせずに行ぎょうをただ読よみ書かきするだけなら、ヘキサゴンは何なにもない場所ばしょに組くんだ足場あしばにすぎない――フレームワークのORMを直接ちょくせつ使つかえばいい。数すうエンドポイント程度ていどの小ちいさなゲートウェイ型がたマイクロサービスでも見送みおくっていい。そして使つかい捨すてのプロトタイプでも、ビジネスモデルが検証けんしょうされるまではフレームワークに直接ちょくせつ結合けつごうするのが正解せいかいであり、これも見送みおくるべきケースだ。


まとめ

ドメインとユースケースをインターフェースの背後はいごに隔離かくりしたことで、私わたしたちは3つのものを手てに入いれた。

コードはテスト可能かのうになった――15行ぎょうの InMemoryUserRepository がデータベースを丸まるごと置おき換かえ、モックフレームワークは一切いっさい登場とうじょうしない。EloquentをDoctrineに、SMTPをMailgunに差さし替かえても、RegisterUser と User は無傷むきずのままだ。書かくのは新あたらしいアダプター一ひとつだけでいい。そしてHTTPコントローラーもコンソールコマンドも、まったく同おなじユースケースを実行じっこうする。

これには最初さいしょ、ファイル数すうと規律きりつという代償だいしょうがかかる。その見返みかえりに手てに入いるのは、下回したまわるインフラより長生ながいきするビジネス層そうだ。

> share on linkedin
>