Ports & Adapters: порты, адаптеры и что куда зависим
Ports & Adapters: порты, адаптеры и что куда зависим
В предыдущем уроке мы увидели, что бизнес-логика должна жить в центре, а инфраструктура - снаружи. Но как именно они общаются? Через порты и адаптеры.
Порт = интерфейс
Порт - это контракт. В Go порт - это interface, в PHP - interface. Он описывает, что нужно сделать, но не как.
// port/user_repo.go
package port
import (
"context"
"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 или файловой системе. Он знает только доменные типы - сущности и объекты-значения, разбор которых лежит в уроке Entity и Value Object.
Адаптер = реализация
Адаптер - это конкретная реализация порта. Он содержит всю инфраструктурную логику:
// 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'],
);
}
}
Driving vs Driven порты
Порты делятся на два типа:
Driving (первичные) порты - через них внешний мир вызывает приложение. Пример: HTTP handler вызывает use-case. Use-case сам является driving-портом - он предоставляет метод Execute, который вызывает handler.
Driven (вторичные) порты - через них приложение обращается к инфраструктуре. Пример: use-case вызывает UserRepo.Create(). Репозиторий - driven-порт.
Handler - driving-адаптер. Он преобразует HTTP-запрос в вызов use-case. PostgresRepo - driven-адаптер. Он преобразует вызов интерфейса в SQL-запрос.
Как выглядит driving-адаптер целиком - парсинг тела, извлечение userID из контекста, маппинг доменных ошибок в коды ответа - показано в уроке HTTP адаптер.
Где живут интерфейсы
В Go принято размещать интерфейс рядом с тем, кто его использует, а не рядом с тем, кто его реализует. Это следствие принципа Interface Segregation и философии Go «accept interfaces, return structs».
Состав методов определяет потребитель
Правило про размещение файла запоминается легко, и на этом обычно
останавливаются. Но переложить интерфейс в пакет port/ мало: он всё ещё
может быть определён не тем, кто его использует.
Вот порт, лежащий в правильном месте и составленный неправильно - по списку того, что умеет таблица:
// port/user_repo.go - папка верная, состав методов нет
type UserRepo interface {
GetByID(ctx context.Context, id int64) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
GetByPhone(ctx context.Context, phone string) (*domain.User, error)
List(ctx context.Context, limit, offset int) ([]*domain.User, error)
Search(ctx context.Context, query string) ([]*domain.User, error)
Create(ctx context.Context, u *domain.User) error
Update(ctx context.Context, u *domain.User) error
Delete(ctx context.Context, id int64) error
}
<?php
// src/Port/UserRepository.php - та же ошибка
declare(strict_types=1);
namespace App\Port;
use App\Domain\User\User;
interface UserRepository
{
public function getById(int $id): ?User;
public function getByEmail(string $email): ?User;
public function getByPhone(string $phone): ?User;
public function list(int $limit, int $offset): array;
public function search(string $query): array;
public function create(User $u): void;
public function update(User $u): void;
public function delete(int $id): void;
}
Это не порт, а перечень возможностей хранилища - тот же DAO, только через интерфейс. А вот контракт под конкретный сценарий:
// app/register_user.go - рядом со сценарием, ровно под его нужды.
// Интерфейс неэкспортируемый: он нужен только здесь.
type userStore interface {
ByEmail(ctx context.Context, email domain.Email) (*domain.User, error)
Add(ctx context.Context, u *domain.User) error
}
<?php
// src/Application/RegisterUser/UserStore.php - в namespace сценария
declare(strict_types=1);
namespace App\Application\RegisterUser;
use App\Domain\User\Email;
use App\Domain\User\User;
interface UserStore
{
public function byEmail(Email $email): ?User;
public function add(User $user): void;
}
Два метода, имена на языке сценария. Одна реализация в адаптере может
удовлетворять пяти таким мелким портам одновременно: в Go это бесплатно
(явного implements нет), в PHP - список через запятую
в implements UserStore, PasswordResetStore, ProfileStore.
Когда общий крупный порт всё же уместен: если методов немного и их
использует почти каждый сценарий - Get/Save/Delete для агрегата
типичный случай, см. репозитории в DDD.
Дробить два метода на два интерфейса ради принципа - другая крайность.
Ориентир: интерфейс переписывают, когда меняется сценарий, а не когда меняется база.
internal/
├── port/ # Интерфейсы (порты) - рядом с use-case
│ ├── user_repo.go
│ └── progress_repo.go
├── app/ # Use-cases - используют порты
│ └── register_user.go
└── adapter/
└── postgres/ # Адаптеры - реализуют порты
└── user_repo.go
Несколько адаптеров для одного порта
// adapter/memory/user_repo.go - для тестов
package memory
import (
"context"
"sync"
"myapp/internal/domain"
)
// UserRepo хранит копии, а не указатели.
//
// Разница не косметическая. С map[int64]*domain.User репозиторий отдавал бы
// тот же объект, что лежит внутри: изменение через метод домена
// «сохранялось» бы само, без вызова Update. Тест на таком фейке зелёный,
// а в проде тот же код теряет изменения - фейк прятал бы ошибку вместо
// того, чтобы её показать.
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
}
func (r *UserRepo) Update(_ context.Context, user *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.users[user.ID]; !ok {
return domain.ErrUserNotFound
}
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- для productionmemory.UserRepo- для юнит-тестовmock.UserRepo(testify/mock) - для проверки вызовов
Dependency Injection: собираем всё вместе
Адаптеры подставляются в use-case через конструктор:
// app/register_user.go
package app
import "myapp/internal/port"
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'));
}
}
Мини-задание
- Определи в своём коде один driven-порт (например, репозиторий) и напиши для него Go interface
- Перенеси SQL-логику из сервиса/handler в отдельный struct-адаптер, который реализует этот интерфейс
- Напиши in-memory адаптер для того же интерфейса (map + mutex)
- Убедись, что use-case принимает интерфейс в конструкторе, а не конкретный тип