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 { /* ... */ }
}
Проблемы: все методы делят одни зависимости (хотя GetStats не нужен mailer), файл разрастается до 500+ строк, тестирование одного сценария требует мокать все зависимости.
Структура use-case
Use-case следует простому шаблону:
- Конструктор принимает порты (интерфейсы), которые нужны этому сценарию
- Input-структура описывает входные данные
- Output-структура описывает результат
- Метод 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);
}
}
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
Главное преимущество: 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 строк. Легко найти, легко понять, легко протестировать.
Мини-задание
- Возьми один метод из существующего «толстого» сервиса и вынеси его в отдельный use-case
- Определи Input и Output структуры для этого use-case
- Напиши метод
Execute(ctx context.Context, in Input) (*Output, error) - Напиши один happy-path тест с in-memory адаптером
- Напиши один тест на ошибку (например, entity не найден)