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'],
);
}
}
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-запрос.
Где живут интерфейсы
В 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
Несколько адаптеров для одного порта
// 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- для productionmemory.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'));
}
}
Мини-задание
- Определи в своём коде один driven-порт (например, репозиторий) и напиши для него Go interface
- Перенеси SQL-логику из сервиса/handler в отдельный struct-адаптер, который реализует этот интерфейс
- Напиши in-memory адаптер для того же интерфейса (map + mutex)
- Убедись, что use-case принимает интерфейс в конструкторе, а не конкретный тип