Use Case: сценарии вместо «толстых сервисов»

Use Case: сценарии вместо «толстых сервисов»

Use-case - это один бизнес-сценарий. Не «UserService с 20 методами», а конкретное действие: «Завершить урок», «Зарегистрировать пользователя», «Создать заказ». Каждый use-case - отдельная структура с одним публичным методом.

Проблема: «толстый» сервис

В типичном Go-проекте появляется UserService с десятком методов:

// Так делать не стоит
type UserService struct {
    db    *sql.DB
    mailer Mailer
    cache  Cache
    logger *slog.Logger
}

func (s *UserService) Register(...)    { ... }
func (s *UserService) Login(...)       { ... }
func (s *UserService) ResetPassword(...) { ... }
func (s *UserService) UpdateProfile(...) { ... }
func (s *UserService) DeleteAccount(...) { ... }
func (s *UserService) GetStats(...)    { ... }
<?php
// Так делать не стоит - все методы делят все зависимости
declare(strict_types=1);

namespace App\Application\Service;

use App\Application\Port\CachePort;
use App\Application\Port\MailerPort;
use App\Application\Port\UserRepositoryPort;
use Psr\Log\LoggerInterface;

final class UserService
{
    public function __construct(
        private readonly UserRepositoryPort $users,
        private readonly MailerPort $mailer,
        private readonly CachePort $cache,
        private readonly LoggerInterface $logger,
    ) {}

    public function register(/* ... */): void { /* ... */ }
    public function login(/* ... */): void { /* ... */ }
    public function resetPassword(/* ... */): void { /* ... */ }
    public function updateProfile(/* ... */): void { /* ... */ }
    public function deleteAccount(/* ... */): void { /* ... */ }
    public function getStats(/* ... */): array { /* ... */ }
}

Толстый UserService против набора use-case по одному на сценарий

Проблемы: все методы делят одни зависимости (хотя GetStats не нужен mailer), файл разрастается до 500+ строк, тестирование одного сценария требует мокать все зависимости.

Структура use-case

Use-case следует простому шаблону:

  1. Конструктор принимает порты (интерфейсы), которые нужны этому сценарию
  2. Input-структура описывает входные данные
  3. Output-структура описывает результат
  4. Метод Execute содержит оркестрацию: вызывает порты и доменные методы
// app/complete_lesson.go
package app

import (
    "context"
    "time"

    "myapp/internal/domain"
    "myapp/internal/port"
)

type CompleteLessonInput struct {
    UserID   int64
    LessonID int64
    TrackID  string
}

type CompleteLessonOutput struct {
    Progress    *domain.Progress
    IsNewRecord bool
}

type CompleteLesson struct {
    progressRepo port.ProgressRepo
    lessonRepo   port.LessonRepo
}

func NewCompleteLesson(pr port.ProgressRepo, lr port.LessonRepo) *CompleteLesson {
    return &CompleteLesson{
        progressRepo: pr,
        lessonRepo:   lr,
    }
}

func (uc *CompleteLesson) Execute(ctx context.Context, in CompleteLessonInput) (*CompleteLessonOutput, error) {
    // 1. Проверяем, существует ли урок
    lesson, err := uc.lessonRepo.GetByID(ctx, in.LessonID)
    if err != nil {
        return nil, fmt.Errorf("get lesson: %w", err)
    }

    // 2. Получаем или создаём прогресс
    progress, err := uc.progressRepo.GetOrCreate(ctx, in.UserID, in.LessonID)
    if err != nil {
        return nil, fmt.Errorf("get progress: %w", err)
    }

    // 3. Доменная логика - вызываем метод entity
    isNew := !progress.IsCompleted()
    if err := progress.Complete(lesson.TrackID); err != nil {
        return nil, err  // доменная ошибка
    }

    // 4. Сохраняем через порт
    if err := uc.progressRepo.Save(ctx, progress); err != nil {
        return nil, fmt.Errorf("save progress: %w", err)
    }

    return &CompleteLessonOutput{
        Progress:    progress,
        IsNewRecord: isNew,
    }, nil
}
<?php
// src/Application/UseCase/CompleteLessonUseCase.php
declare(strict_types=1);

namespace App\Application\UseCase;

use App\Application\Port\LessonRepositoryPort;
use App\Application\Port\ProgressRepositoryPort;
use App\Domain\Progress;

final readonly class CompleteLessonInput
{
    public function __construct(
        public int $userId,
        public int $lessonId,
        public string $trackId,
    ) {}
}

final class CompleteLessonOutput
{
    public function __construct(
        public readonly Progress $progress,
        public readonly bool $isNewRecord,
    ) {}
}

final class CompleteLessonUseCase
{
    public function __construct(
        private readonly ProgressRepositoryPort $progressRepo,
        private readonly LessonRepositoryPort $lessonRepo,
    ) {}

    public function execute(CompleteLessonInput $in): CompleteLessonOutput
    {
        // 1. Проверяем, существует ли урок
        $lesson = $this->lessonRepo->getById($in->lessonId);

        // 2. Получаем или создаём прогресс
        $progress = $this->progressRepo->getOrCreate($in->userId, $in->lessonId);

        // 3. Доменная логика - вызываем метод entity (бросит доменное исключение при нарушении правила)
        $isNew = !$progress->isCompleted();
        $progress->complete($lesson->trackId());

        // 4. Сохраняем через порт
        $this->progressRepo->save($progress);

        return new CompleteLessonOutput($progress, $isNew);
    }
}
Обрати внимание: use-case не проверяет, можно ли завершить урок повторно. Это делает доменный метод `progress.Complete()`. Use-case только вызывает порты в правильном порядке и передаёт данные между ними.

Input/Output вместо примитивов

Почему не передавать просто (userID, lessonID int64) в Execute?

// Плохо: при добавлении параметра меняется сигнатура
func (uc *CompleteLesson) Execute(ctx context.Context, userID, lessonID int64) error

// Плохо: легко перепутать два int64 местами
uc.Execute(ctx, lessonID, userID) // компилятор не поймает ошибку

// Хорошо: именованные поля, расширяемость
uc.Execute(ctx, CompleteLessonInput{
    UserID:   42,
    LessonID: 7,
})
<?php
// Плохо: позиционные аргументы легко перепутать
declare(strict_types=1);

$uc->execute(7, 42); // 42 - это userId или lessonId?

// Хорошо: именованные аргументы PHP 8.0+
$uc->execute(new CompleteLessonInput(
    userId: 42,
    lessonId: 7,
    trackId: 'go',
));

// Альтернатива: named arguments на самом методе
final class CompleteLessonUseCase
{
    public function execute(int $userId, int $lessonId, string $trackId): CompleteLessonOutput
    {
        // ...
    }
}

// Вызывающий код безопасен от перестановки
$uc->execute(userId: 42, lessonId: 7, trackId: 'go');

Input-структура решает три задачи: именованные поля исключают путаницу, добавление нового поля не ломает вызывающий код, структуру удобно создавать в тестах.

Обработка ошибок: доменные vs инфраструктурные

Use-case работает с двумя типами ошибок:

// Доменные ошибки - определены в пакете domain
var (
    ErrLessonAlreadyCompleted = errors.New("lesson already completed")
    ErrInvalidProgress        = errors.New("invalid progress value")
)

// Инфраструктурные ошибки - приходят из адаптеров
// sql.ErrNoRows, context.DeadlineExceeded, и т.д.
<?php
// src/Domain/ProgressError.php - типизированные доменные исключения
declare(strict_types=1);

namespace App\Domain;

final class ProgressError extends \DomainException
{
    public static function lessonAlreadyCompleted(): self
    {
        return new self('lesson already completed');
    }

    public static function invalidProgress(): self
    {
        return new self('invalid progress value');
    }
}

// Инфраструктурные - из адаптеров: Doctrine\DBAL\Exception, RuntimeException и т.д.

Use-case оборачивает инфраструктурные ошибки для контекста, а доменные пробрасывает как есть:

func (uc *CompleteLesson) Execute(ctx context.Context, in CompleteLessonInput) (*CompleteLessonOutput, error) {
    progress, err := uc.progressRepo.GetOrCreate(ctx, in.UserID, in.LessonID)
    if err != nil {
        // Инфраструктурная - оборачиваем
        return nil, fmt.Errorf("complete lesson: get progress: %w", err)
    }

    if err := progress.Complete(); err != nil {
        // Доменная - пробрасываем напрямую
        return nil, err
    }
    // ...
}
<?php
declare(strict_types=1);

public function execute(CompleteLessonInput $in): CompleteLessonOutput
{
    try {
        $progress = $this->progressRepo->getOrCreate($in->userId, $in->lessonId);
    } catch (\Doctrine\DBAL\Exception $e) {
        // Инфраструктурная - оборачиваем с контекстом через previous
        throw new \RuntimeException('complete lesson: get progress', previous: $e);
    }

    // Доменная исключения - не ловим, пробрасываем
    $progress->complete($in->trackId);

    return new CompleteLessonOutput($progress, true);
}

Handler потом различает их для выбора HTTP-статуса:

if errors.Is(err, domain.ErrLessonAlreadyCompleted) {
    // 409 Conflict
} else {
    // 500 Internal Server Error
}
<?php
declare(strict_types=1);

try {
    $uc->execute($input);
} catch (\App\Domain\ProgressError $e) {
    // Доменная - 409 Conflict / 400 Bad Request по типу
    return new JsonResponse(['error' => $e->getMessage()], 409);
} catch (\Throwable $e) {
    // Инфраструктурная - 500, детали в лог, не в ответ
    $logger->error('use-case failed', ['exception' => $e]);
    return new JsonResponse(['error' => 'internal error'], 500);
}
Логирование - инфраструктурная деталь. Use-case возвращает ошибку, а handler (или middleware) решает, как её залогировать. Это сохраняет use-case чистым и тестируемым.

Тестирование use-case

Главное преимущество: use-case тестируется без базы данных и HTTP. Подставляем фейковые адаптеры:

func TestCompleteLesson_Success(t *testing.T) {
    // Arrange: фейковые адаптеры
    progressRepo := memory.NewProgressRepo()
    lessonRepo := memory.NewLessonRepo()

    // Создаём тестовые данные
    lessonRepo.Add(&domain.Lesson{ID: 1, TrackID: "go", Slug: "variables"})
    progressRepo.Add(&domain.Progress{UserID: 42, LessonID: 1, Percent: 50})

    uc := app.NewCompleteLesson(progressRepo, lessonRepo)

    // Act
    out, err := uc.Execute(context.Background(), app.CompleteLessonInput{
        UserID:   42,
        LessonID: 1,
    })

    // Assert
    assert.NoError(t, err)
    assert.True(t, out.IsNewRecord)
    assert.True(t, out.Progress.IsCompleted())
}

func TestCompleteLesson_LessonNotFound(t *testing.T) {
    progressRepo := memory.NewProgressRepo()
    lessonRepo := memory.NewLessonRepo() // пустой - урока нет

    uc := app.NewCompleteLesson(progressRepo, lessonRepo)

    _, err := uc.Execute(context.Background(), app.CompleteLessonInput{
        UserID:   42,
        LessonID: 999,
    })

    assert.ErrorIs(t, err, domain.ErrLessonNotFound)
}
<?php
// tests/Application/UseCase/CompleteLessonUseCaseTest.php
declare(strict_types=1);

namespace App\Tests\Application\UseCase;

use App\Application\UseCase\CompleteLessonInput;
use App\Application\UseCase\CompleteLessonUseCase;
use App\Domain\LessonError;
use App\Tests\Fakes\InMemoryLessonRepository;
use App\Tests\Fakes\InMemoryProgressRepository;
use PHPUnit\Framework\TestCase;

final class CompleteLessonUseCaseTest extends TestCase
{
    public function testCompletesLessonSuccessfully(): void
    {
        $lessons = new InMemoryLessonRepository();
        $progress = new InMemoryProgressRepository();
        $lessons->add(new Lesson(id: 1, trackId: 'go', slug: 'variables'));

        $uc = new CompleteLessonUseCase($progress, $lessons);

        $out = $uc->execute(new CompleteLessonInput(userId: 42, lessonId: 1, trackId: 'go'));

        self::assertTrue($out->isNewRecord);
        self::assertTrue($out->progress->isCompleted());
    }

    public function testThrowsWhenLessonNotFound(): void
    {
        $uc = new CompleteLessonUseCase(
            new InMemoryProgressRepository(),
            new InMemoryLessonRepository(), // пустой
        );

        $this->expectException(LessonError::class);
        $uc->execute(new CompleteLessonInput(userId: 42, lessonId: 999, trackId: 'go'));
    }
}

Тесты запускаются за миллисекунды, не требуют Docker или тестовой базы, и проверяют именно бизнес-логику.

Один use-case = один файл

Простое правило организации кода:

app/
├── complete_lesson.go        # CompleteLesson use-case
├── complete_lesson_test.go   # тесты к нему
├── register_user.go          # RegisterUser use-case
├── register_user_test.go
├── submit_quiz.go            # SubmitQuiz use-case
└── submit_quiz_test.go

Каждый файл 50-100 строк. Легко найти, легко понять, легко протестировать.

Если `Execute` занимает больше 40-50 строк - скорее всего, часть логики должна уйти в доменный метод или в отдельный use-case. Use-case оркестрирует, а не вычисляет.

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

  • Возьми один метод из существующего «толстого» сервиса и вынеси его в отдельный use-case
  • Определи Input и Output структуры для этого use-case
  • Напиши метод Execute(ctx context.Context, in Input) (*Output, error)
  • Напиши один happy-path тест с in-memory адаптером
  • Напиши один тест на ошибку (например, entity не найден)

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