Ports & Adapters: порты, адаптеры и что куда зависим

Ports & Adapters: порты, адаптеры и что куда зависим

В предыдущем уроке мы увидели, что бизнес-логика должна жить в центре, а инфраструктура - снаружи. Но как именно они общаются? Через порты и адаптеры.

Порт = интерфейс

Порт - это контракт. В Go порт - это interface, в PHP - interface. Он описывает, что нужно сделать, но не как.

// port/user_repo.go
package port

import "myapp/internal/domain"

type UserRepo interface {
    GetByID(ctx context.Context, id int64) (*domain.User, error)
    GetByEmail(ctx context.Context, email string) (*domain.User, error)
    Create(ctx context.Context, user *domain.User) error
}
<?php
// src/Application/Port/UserRepositoryPort.php
declare(strict_types=1);

namespace App\Application\Port;

use App\Domain\User;

interface UserRepositoryPort
{
    public function getById(int $id): ?User;

    public function getByEmail(string $email): ?User;

    public function create(User $user): void;
}

Порт ничего не знает о PostgreSQL, MongoDB или файловой системе. Он знает только доменные типы.

Адаптер = реализация

Адаптер - это конкретная реализация порта. Он содержит всю инфраструктурную логику:

// adapter/postgres/user_repo.go
package postgres

import (
    "context"
    "database/sql"

    "myapp/internal/domain"
)

type UserRepo struct {
    db *sql.DB
}

func NewUserRepo(db *sql.DB) *UserRepo {
    return &UserRepo{db: db}
}

func (r *UserRepo) GetByID(ctx context.Context, id int64) (*domain.User, error) {
    row := r.db.QueryRowContext(ctx,
        "SELECT id, email, name, created_at FROM users WHERE id = $1", id,
    )
    var u domain.User
    err := row.Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
    if err == sql.ErrNoRows {
        return nil, domain.ErrUserNotFound
    }
    return &u, err
}

func (r *UserRepo) Create(ctx context.Context, user *domain.User) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id",
        user.Email, user.Name, user.PasswordHash,
    )
    return err
}
<?php
// src/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use App\Application\Port\UserRepositoryPort;
use App\Domain\User;
use Doctrine\DBAL\Connection;

final class DoctrineUserRepository implements UserRepositoryPort
{
    public function __construct(
        private readonly Connection $connection,
    ) {}

    public function getById(int $id): ?User
    {
        $row = $this->connection->fetchAssociative(
            'SELECT id, email, name, password_hash, created_at FROM users WHERE id = :id',
            ['id' => $id],
        );
        return $row === false ? null : $this->hydrate($row);
    }

    public function getByEmail(string $email): ?User
    {
        $row = $this->connection->fetchAssociative(
            'SELECT id, email, name, password_hash, created_at FROM users WHERE email = :email',
            ['email' => $email],
        );
        return $row === false ? null : $this->hydrate($row);
    }

    public function create(User $user): void
    {
        $this->connection->insert('users', [
            'email' => $user->email(),
            'name' => $user->name(),
            'password_hash' => $user->passwordHash(),
        ]);
    }

    /** @param array<string,mixed> $row */
    private function hydrate(array $row): User
    {
        return User::fromStorage(
            id: (int) $row['id'],
            email: (string) $row['email'],
            name: (string) $row['name'],
            passwordHash: (string) $row['password_hash'],
        );
    }
}
В Go не нужно писать `implements UserRepo`. Если структура имеет все методы из интерфейса - она его реализует. Компилятор проверит это автоматически. Это делает Go особенно удобным для Ports & Adapters: адаптер не зависит от пакета с портом.

Driving vs Driven порты

Порты делятся на два типа:

Driving (первичные) порты - через них внешний мир вызывает приложение. Пример: HTTP handler вызывает use-case. Use-case сам является driving-портом - он предоставляет метод Execute, который вызывает handler.

Driven (вторичные) порты - через них приложение обращается к инфраструктуре. Пример: use-case вызывает UserRepo.Create(). Репозиторий - driven-порт.

Driving и driven порты: handler слева как driving-адаптер, репозиторий справа как driven-адаптер

Handler - driving-адаптер. Он преобразует HTTP-запрос в вызов use-case. PostgresRepo - driven-адаптер. Он преобразует вызов интерфейса в SQL-запрос.

Где живут интерфейсы

В Go принято размещать интерфейс рядом с тем, кто его использует, а не рядом с тем, кто его реализует. Это следствие принципа Interface Segregation и философии Go «accept interfaces, return structs».

internal/
├── port/              # Интерфейсы (порты) - рядом с use-case
│   ├── user_repo.go
│   └── progress_repo.go
├── app/               # Use-cases - используют порты
│   └── register_user.go
└── adapter/
    └── postgres/      # Адаптеры - реализуют порты
        └── user_repo.go
Если `UserRepo` interface лежит в пакете `postgres/` - это неправильно. Получается, что use-case импортирует инфраструктурный пакет ради интерфейса. Зависимость идёт в неправильном направлении.

Несколько адаптеров для одного порта

Главная сила портов - возможность подставить разные адаптеры под один интерфейс. Use-case даже не знает, какая реализация под ним стоит: `postgres.UserRepo` в продакшене, `memory.UserRepo` в юнит-тестах, `mock.UserRepo` - когда нужно проверить вызовы.
// adapter/memory/user_repo.go - для тестов
package memory

import (
    "context"
    "sync"

    "myapp/internal/domain"
)

type UserRepo struct {
    mu    sync.RWMutex
    users map[int64]*domain.User
    nextID int64
}

func NewUserRepo() *UserRepo {
    return &UserRepo{users: make(map[int64]*domain.User), nextID: 1}
}

func (r *UserRepo) GetByID(_ context.Context, id int64) (*domain.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    u, ok := r.users[id]
    if !ok {
        return nil, domain.ErrUserNotFound
    }
    return u, nil
}

func (r *UserRepo) Create(_ context.Context, user *domain.User) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    user.ID = r.nextID
    r.nextID++
    r.users[user.ID] = user
    return nil
}
<?php
// tests/Fakes/InMemoryUserRepository.php
declare(strict_types=1);

namespace App\Tests\Fakes;

use App\Application\Port\UserRepositoryPort;
use App\Domain\User;

final class InMemoryUserRepository implements UserRepositoryPort
{
    /** @var array<int, User> */
    private array $users = [];
    private int $nextId = 1;

    public function getById(int $id): ?User
    {
        return $this->users[$id] ?? null;
    }

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

    public function create(User $user): void
    {
        $user->assignId($this->nextId++);
        $this->users[$user->id()] = $user;
    }
}

PHP-FPM запускает request → выполняет → завершается. In-process mutex для in-memory адаптера в тестах не нужен - тесты выполняются последовательно в одном процессе. Если нужна shared state между процессами, используй Redis или APCu.

Теперь у одного порта UserRepo три адаптера:

  • postgres.UserRepo - для production
  • memory.UserRepo - для юнит-тестов
  • mock.UserRepo (testify/mock) - для проверки вызовов

Dependency Injection: собираем всё вместе

Адаптеры подставляются в use-case через конструктор:

// app/register_user.go
package app

type RegisterUser struct {
    users port.UserRepo   // принимаем интерфейс, не конкретный тип
}

func NewRegisterUser(users port.UserRepo) *RegisterUser {
    return &RegisterUser{users: users}
}
<?php
// src/Application/UseCase/RegisterUserUseCase.php
declare(strict_types=1);

namespace App\Application\UseCase;

use App\Application\Port\NotificationPort;
use App\Application\Port\UserRepositoryPort;

final class RegisterUserUseCase
{
    public function __construct(
        private readonly UserRepositoryPort $users,
        private readonly NotificationPort $notifier,
    ) {}
}

В main.go (или Wire-конфигурации) собираем зависимости:

func main() {
    db := connectDB()

    // Production: Postgres-адаптер
    userRepo := postgres.NewUserRepo(db)

    // Use-case получает адаптер через интерфейс
    registerUC := app.NewRegisterUser(userRepo)

    // Handler получает use-case
    handler := http.NewRegisterHandler(registerUC)

    router.Post("/users", handler.Handle)
}
# config/services.yaml - Symfony DI связывает интерфейс с реализацией
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # Production: связываем порт с Doctrine-адаптером
    App\Application\Port\UserRepositoryPort:
        alias: App\Infrastructure\Persistence\Doctrine\DoctrineUserRepository

В тестах - другой адаптер:

func TestRegisterUser(t *testing.T) {
    // Тест: in-memory адаптер, без базы данных
    userRepo := memory.NewUserRepo()
    uc := app.NewRegisterUser(userRepo)

    err := uc.Execute(context.Background(), app.RegisterInput{
        Email: "test@example.com",
        Name:  "Test",
    })
    assert.NoError(t, err)
}
<?php
// tests/Application/UseCase/RegisterUserUseCaseTest.php
declare(strict_types=1);

namespace App\Tests\Application\UseCase;

use App\Application\UseCase\RegisterUserUseCase;
use App\Tests\Fakes\InMemoryUserRepository;
use App\Tests\Fakes\NullNotifier;
use PHPUnit\Framework\TestCase;

final class RegisterUserUseCaseTest extends TestCase
{
    public function testRegistersUserWithInMemoryAdapter(): void
    {
        $users = new InMemoryUserRepository();
        $uc = new RegisterUserUseCase($users, new NullNotifier());

        $uc->execute('test@example.com', 'Test');

        self::assertNotNull($users->getByEmail('test@example.com'));
    }
}
В BackendStart для dependency injection используется Google Wire. Он генерирует код сборки зависимостей на этапе компиляции - никакой рефлексии в runtime.

Мини-задание

  • Определи в своём коде один driven-порт (например, репозиторий) и напиши для него Go interface
  • Перенеси SQL-логику из сервиса/handler в отдельный struct-адаптер, который реализует этот интерфейс
  • Напиши in-memory адаптер для того же интерфейса (map + mutex)
  • Убедись, что use-case принимает интерфейс в конструкторе, а не конкретный тип

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.