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. フレームワークに溶接ようせつされている

RequestResponse、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実装じっそうする EloquentUserRepositoryMailerInterface実装じっそうする 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をて、ユースケースを実行じっこうする。DomainException422 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にえても、RegisterUserUser無傷むきずのままだ。くのはあたらしいアダプターひとつだけでいい。そしてHTTPコントローラーもコンソールコマンドも、まったくおなじユースケースを実行じっこうする。

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

> share on linkedin
>