Практика: приводим «грязный» модуль в порядок
Сейчас сделаем то, что реально прокачивает: возьмём «грязный» кусок кода и приведём в порядок, применяя все принципы из предыдущих уроков.
Исходник: грязный код
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-ответ).
Шаг 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 для автоматической проверки