Практика: приводим «грязный» модуль в порядок

Сейчас сделаем то, что реально прокачивает: возьмём «грязный» кусок кода и приведём в порядок, применяя все принципы из предыдущих уроков.

Исходник: грязный код

func handle(w http.ResponseWriter, r *http.Request) {
    var d struct {
        U int    `json:"u"`
        I int    `json:"i"`
        Q int    `json:"q"`
    }
    json.NewDecoder(r.Body).Decode(&d)
    if d.U != 0 {
        if d.I != 0 {
            if d.Q > 0 {
                var u User
                db.First(&u, d.U)
                if u.ID != 0 {
                    if u.Active {
                        var item Item
                        db.First(&item, d.I)
                        if item.ID != 0 {
                            if item.Stock >= d.Q {
                                // do purchase
                                item.Stock -= d.Q
                                db.Save(&item)
                                order := Order{UserID: u.ID, ItemID: item.ID, Qty: d.Q,
                                    Total: float64(d.Q) * item.Price}
                                db.Create(&order)
                                // send email
                                msg := fmt.Sprintf("Order #%d confirmed", order.ID)
                                smtp.SendMail("smtp.example.com:587", nil,
                                    "noreply@shop.com", []string{u.Email}, []byte(msg))
                                w.WriteHeader(200)
                                json.NewEncoder(w).Encode(map[string]interface{}{
                                    "ok": true, "order_id": order.ID,
                                })
                            } else {
                                http.Error(w, "out of stock", 400)
                            }
                        } else {
                            http.Error(w, "item not found", 404)
                        }
                    } else {
                        http.Error(w, "user inactive", 403)
                    }
                } else {
                    http.Error(w, "user not found", 404)
                }
            } else {
                http.Error(w, "bad qty", 400)
            }
        } else {
            http.Error(w, "bad item", 400)
        }
    } else {
        http.Error(w, "bad user", 400)
    }
}

То же самое в PHP - типичный legacy-контроллер Symfony до рефакторинга:

<?php

final class PurchaseController
{
    public function handle(Request $request): Response
    {
        $d = json_decode($request->getContent(), true);
        if ($d['u'] != 0) {
            if ($d['i'] != 0) {
                if ($d['q'] > 0) {
                    $u = $this->em->find(User::class, $d['u']);
                    if ($u !== null) {
                        if ($u->active) {
                            $item = $this->em->find(Item::class, $d['i']);
                            if ($item !== null) {
                                if ($item->stock >= $d['q']) {
                                    // do purchase
                                    $item->stock -= $d['q'];
                                    $order = new Order();
                                    $order->user = $u;
                                    $order->item = $item;
                                    $order->qty = $d['q'];
                                    $order->total = $d['q'] * $item->price;
                                    $this->em->persist($order);
                                    $this->em->flush();
                                    // send email
                                    mail($u->email, 'Order confirmed', "Order #{$order->id}");
                                    return new JsonResponse(['ok' => true, 'order_id' => $order->id]);
                                } else {
                                    return new Response('out of stock', 400);
                                }
                            } else {
                                return new Response('item not found', 404);
                            }
                        } else {
                            return new Response('user inactive', 403);
                        }
                    } else {
                        return new Response('user not found', 404);
                    }
                } else {
                    return new Response('bad qty', 400);
                }
            } else {
                return new Response('bad item', 400);
            }
        } else {
            return new Response('bad user', 400);
        }
    }
}

Что здесь плохо:

Проблема                  Принцип из урока
────────────────────────  ──────────────────────────────
Однобуквенные имена       Урок 2: именование
7 уровней вложенности     Урок 4: ранние return
Всё в одной функции       Урок 3: маленькие функции
Ошибки без контекста       Урок 5: обработка ошибок
SQL в хендлере             Урок 7: структура проекта
Нет тестов                 Урок 8: рефакторинг с тестами

Шаг 1: Guard clauses + понятные ошибки

Убираем лесенку, ставим проверки в начало:

func handlePurchase(w http.ResponseWriter, r *http.Request) {
    var req PurchaseRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    if req.UserID == 0 {
        http.Error(w, "user_id is required", http.StatusBadRequest)
        return
    }
    if req.ItemID == 0 {
        http.Error(w, "item_id is required", http.StatusBadRequest)
        return
    }
    if req.Quantity <= 0 {
        http.Error(w, "quantity must be positive", http.StatusBadRequest)
        return
    }
    // ... основная логика на уровне 0
}
<?php
declare(strict_types=1);

final class PurchaseController
{
    public function handle(Request $request): Response
    {
        try {
            $data = json_decode($request->getContent(), associative: true, flags: JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return new JsonResponse(['error' => 'invalid JSON'], Response::HTTP_BAD_REQUEST);
        }

        if (($data['user_id'] ?? 0) === 0) {
            return new JsonResponse(['error' => 'user_id is required'], Response::HTTP_BAD_REQUEST);
        }
        if (($data['item_id'] ?? 0) === 0) {
            return new JsonResponse(['error' => 'item_id is required'], Response::HTTP_BAD_REQUEST);
        }
        if (($data['quantity'] ?? 0) <= 0) {
            return new JsonResponse(['error' => 'quantity must be positive'], Response::HTTP_BAD_REQUEST);
        }

        // ... основная логика на уровне 0
    }
}

Уже лучше: имена понятные, ошибки конкретные, вложенность - ноль.

Шаг 2: Выделяем слои (domain, use case, adapter)

Идея слоёв напрямую следует из DIP: бизнес-логика владеет интерфейсами, а адаптеры на периферии их реализуют. На уровне HTTP-слоя - это тонкий handler как Facade над use case.

// domain/order.go - сущности и бизнес-правила
type PurchaseRequest struct {
    UserID   int `json:"user_id"`
    ItemID   int `json:"item_id"`
    Quantity int `json:"quantity"`
}

type UserRepository interface {
    GetByID(ctx context.Context, id int) (*User, error)
}

type ItemRepository interface {
    GetByID(ctx context.Context, id int) (*Item, error)
    DecreaseStock(ctx context.Context, id int, qty int) error
}

type OrderRepository interface {
    Create(ctx context.Context, order *Order) error
}
<?php
declare(strict_types=1);

// src/Domain/Purchase/PurchaseCommand.php - DTO (immutable)
namespace App\Domain\Purchase;

final readonly class PurchaseCommand
{
    public function __construct(
        public int $userId,
        public int $itemId,
        public int $quantity,
    ) {}
}

// src/Domain/User/UserRepositoryInterface.php
namespace App\Domain\User;

interface UserRepositoryInterface
{
    public function findById(int $id): ?User;
}

// src/Domain/Item/ItemRepositoryInterface.php
namespace App\Domain\Item;

interface ItemRepositoryInterface
{
    public function findById(int $id): ?Item;

    public function decreaseStock(int $id, int $qty): void;
}

// src/Domain/Order/OrderRepositoryInterface.php
namespace App\Domain\Order;

interface OrderRepositoryInterface
{
    public function save(Order $order): void;
}
// usecase/purchase.go - бизнес-сценарий
type PurchaseUC struct {
    users    domain.UserRepository
    items    domain.ItemRepository
    orders   domain.OrderRepository
    notifier domain.Notifier
}

func (uc *PurchaseUC) Execute(ctx context.Context, req PurchaseRequest) (*Order, error) {
    user, err := uc.users.GetByID(ctx, req.UserID)
    if err != nil {
        return nil, fmt.Errorf("get user %d: %w", req.UserID, err)
    }
    if !user.Active {
        return nil, ErrUserInactive
    }

    item, err := uc.items.GetByID(ctx, req.ItemID)
    if err != nil {
        return nil, fmt.Errorf("get item %d: %w", req.ItemID, err)
    }
    if item.Stock < req.Quantity {
        return nil, ErrOutOfStock
    }

    order := NewOrder(user, item, req.Quantity)
    if err := uc.orders.Create(ctx, order); err != nil {
        return nil, fmt.Errorf("create order: %w", err)
    }
    if err := uc.items.DecreaseStock(ctx, item.ID, req.Quantity); err != nil {
        return nil, fmt.Errorf("decrease stock: %w", err)
    }

    uc.notifier.OrderCreated(ctx, order)
    return order, nil
}
<?php
declare(strict_types=1);

// src/Application/Purchase/PurchaseHandler.php - use case
namespace App\Application\Purchase;

use App\Domain\Item\ItemRepositoryInterface;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Purchase\PurchaseCommand;
use App\Domain\User\UserRepositoryInterface;

final readonly class PurchaseHandler
{
    public function __construct(
        private UserRepositoryInterface $users,
        private ItemRepositoryInterface $items,
        private OrderRepositoryInterface $orders,
        private NotifierInterface $notifier,
    ) {}

    public function __invoke(PurchaseCommand $command): Order
    {
        $user = $this->users->findById($command->userId)
            ?? throw new UserNotFoundException($command->userId);

        if (!$user->active) {
            throw new UserInactiveException($command->userId);
        }

        $item = $this->items->findById($command->itemId)
            ?? throw new ItemNotFoundException($command->itemId);

        if ($item->stock < $command->quantity) {
            throw new OutOfStockException($item->id);
        }

        $order = Order::create($user, $item, $command->quantity);
        $this->orders->save($order);
        $this->items->decreaseStock($item->id, $command->quantity);

        $this->notifier->orderCreated($order);

        return $order;
    }
}
// adapter/http/purchase_handler.go - тонкий хендлер
func (h *Handler) HandlePurchase(w http.ResponseWriter, r *http.Request) {
    var req domain.PurchaseRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    if err := validatePurchaseRequest(req); err != nil {
        respondError(w, err.Error(), http.StatusBadRequest)
        return
    }

    order, err := h.purchaseUC.Execute(r.Context(), req)
    if err != nil {
        switch {
        case errors.Is(err, domain.ErrNotFound):
            respondError(w, "not found", http.StatusNotFound)
        case errors.Is(err, domain.ErrUserInactive):
            respondError(w, "user is inactive", http.StatusForbidden)
        case errors.Is(err, domain.ErrOutOfStock):
            respondError(w, "out of stock", http.StatusConflict)
        default:
            log.Printf("purchase error: %v", err)
            respondError(w, "internal error", http.StatusInternalServerError)
        }
        return
    }
    respondJSON(w, http.StatusOK, map[string]interface{}{
        "ok": true, "order_id": order.ID,
    })
}
<?php
declare(strict_types=1);

// src/Infrastructure/Http/PurchaseController.php - тонкий контроллер
namespace App\Infrastructure\Http;

use App\Application\Purchase\PurchaseHandler;
use App\Domain\Purchase\PurchaseCommand;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final readonly class PurchaseController
{
    public function __construct(private PurchaseHandler $handler) {}

    #[Route('/api/purchase', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        try {
            $data = json_decode($request->getContent(), associative: true, flags: JSON_THROW_ON_ERROR);
            $command = new PurchaseCommand(
                userId: (int) ($data['user_id'] ?? 0),
                itemId: (int) ($data['item_id'] ?? 0),
                quantity: (int) ($data['quantity'] ?? 0),
            );

            $order = ($this->handler)($command);

            return new JsonResponse(['ok' => true, 'order_id' => $order->id]);
        } catch (\JsonException) {
            return new JsonResponse(['error' => 'invalid JSON'], Response::HTTP_BAD_REQUEST);
        } catch (UserNotFoundException | ItemNotFoundException) {
            return new JsonResponse(['error' => 'not found'], Response::HTTP_NOT_FOUND);
        } catch (UserInactiveException) {
            return new JsonResponse(['error' => 'user is inactive'], Response::HTTP_FORBIDDEN);
        } catch (OutOfStockException) {
            return new JsonResponse(['error' => 'out of stock'], Response::HTTP_CONFLICT);
        }
    }
}

В Symfony стандарт - match или цепочка catch для маппинга доменных исключений на HTTP-статусы. Альтернатива - ExceptionListener (один централизованный listener превращает любое доменное исключение в JSON-ответ).

Мы не стали «умнее». Мы стали **понятнее**. И теперь каждый слой можно тестировать независимо: use case с моками, handler с httptest, репозиторий с тестовой БД.

Шаг 3: Тесты для use case

func TestPurchaseUC_Execute_Success(t *testing.T) {
    users := &mockUserRepo{user: &User{ID: 1, Active: true, Email: "a@b.com"}}
    items := &mockItemRepo{item: &Item{ID: 10, Stock: 5, Price: 100}}
    orders := &mockOrderRepo{}
    notifier := &mockNotifier{}

    uc := &PurchaseUC{users: users, items: items, orders: orders, notifier: notifier}
    order, err := uc.Execute(context.Background(), PurchaseRequest{
        UserID: 1, ItemID: 10, Quantity: 2,
    })

    assert.NoError(t, err)
    assert.Equal(t, 200.0, order.Total)
    assert.True(t, notifier.called)
}

func TestPurchaseUC_Execute_InactiveUser(t *testing.T) {
    users := &mockUserRepo{user: &User{ID: 1, Active: false}}
    uc := &PurchaseUC{users: users}

    _, err := uc.Execute(context.Background(), PurchaseRequest{UserID: 1})
    assert.ErrorIs(t, err, ErrUserInactive)
}
<?php
declare(strict_types=1);

namespace App\Tests\Application\Purchase;

use App\Application\Purchase\PurchaseHandler;
use App\Domain\Purchase\PurchaseCommand;
use App\Domain\User\UserInactiveException;
use PHPUnit\Framework\TestCase;

final class PurchaseHandlerTest extends TestCase
{
    public function testSuccess(): void
    {
        $users = new InMemoryUserRepository([new User(id: 1, active: true, email: 'a@b.com')]);
        $items = new InMemoryItemRepository([new Item(id: 10, stock: 5, price: 100.0)]);
        $orders = new InMemoryOrderRepository();
        $notifier = new SpyNotifier();

        $handler = new PurchaseHandler($users, $items, $orders, $notifier);
        $order = $handler(new PurchaseCommand(userId: 1, itemId: 10, quantity: 2));

        self::assertSame(200.0, $order->total);
        self::assertTrue($notifier->wasCalled());
    }

    public function testInactiveUserThrows(): void
    {
        $users = new InMemoryUserRepository([new User(id: 1, active: false)]);
        $handler = new PurchaseHandler($users, /* ... */);

        $this->expectException(UserInactiveException::class);
        $handler(new PurchaseCommand(userId: 1, itemId: 10, quantity: 1));
    }
}

In-memory реализации интерфейсов часто чище моков: они проще читаются и не ломаются на смене сигнатур. PHPUnit createMock(UserRepositoryInterface::class) - тоже вариант, но fakes/stubs обычно выигрывают по читаемости.

Итого: что изменилось

До                            После
────────────────────────────  ────────────────────────────
1 функция, 50 строк           3 слоя, 5+ функций
7 уровней вложенности          0 уровней (guard clauses)
Однобуквенные имена            Осмысленные имена
SQL в хендлере                 Репозиторий за интерфейсом
Ошибки: "bad user"             Ошибки: sentinel + контекст
Тесты: невозможны              Тесты: unit + integration
smtp.SendMail в хендлере       Notifier за интерфейсом

Интеграция чистого кода в CI

Чтобы качество не деградировало, добавь автоматические проверки:

# golangci-lint - стиль и сложность
golangci-lint run ./...

# gofumpt - форматирование (строже gofmt)
gofumpt -d .

# go test с race detector
go test -race -coverprofile=coverage.out ./...

# проверка покрытия
go tool cover -func=coverage.out
# Минимальный .golangci.yml
linters:
  enable:
 - revive       # стиль и именование
 - cyclop       # цикломатическая сложность
 - gocritic     # антипаттерны
 - errcheck     # необработанные ошибки
 - gosec        # безопасность

linters-settings:
  cyclop:
    max-complexity: 15

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

  • Возьми реальный модуль из своего проекта (самый «грязный»)
  • Примени шаги 1-3 из этого урока: guard clauses → слои → тесты
  • В MR добавь описание: какие принципы чистого кода применил
  • Настрой golangci-lint в CI для автоматической проверки

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