Привет, Хабр!

Сегодня разберёмся, зачем Go-проекту слой Application / Use-Case: как он герметично изолирует бизнес-логику, позволяет переключаться между HTTP, gRPC, Cron-джобами и очередями, а заодно экономит тесты и нервные клетки.

Где живёт слой Application?

/internal
    /domain        // сущности, политики
    /app           // <-- наши use cases
    /adapters
        /http      // delivery
        /cron
        /grpc
    /infra
        /postgres  // репозитории, external clients
        /redis

Domain — неизменный центр: сущности + инварианты. App — публичный API домена: orchestrates что должно случиться, абстрагируется от как. Adapters — порты, подчиняются App. Infra — реализация интерфейсов, зависимая от библиотек/SDK.

Кейс: создаём заказ

Базовые сущности (/domain/order.go)

type OrderStatus string

const (
    StatusNew  OrderStatus = "new"
    StatusPaid OrderStatus = "paid"
)

type Order struct {
    ID         uuid.UUID
    UserID     uuid.UUID
    Items      []Item
    Status     OrderStatus
    CreatedAt  time.Time
}

func (o *Order) Total() money.Money {
    var sum int64
    for _, it := range o.Items {
        sum += int64(it.Qty) * it.Price
    }
    return money.FromMinor(sum, "RUB")
}

Здесь нет ни SQL-тегов, ни JSON-аннотаций — домен выше транспорта и хранилища.

Порт-репозиторий (/domain/order_repository.go)

type OrderRepository interface {
    Save(ctx context.Context, o Order) error
    WithTx(ctx context.Context, fn func(repo OrderRepository) error) error
}

Синхронная обёртка WithTx позволяет use-case управлять транзакцией, не зная конкретной БДшки.

Use Case: CreateOrder

// /app/create_order.go
type CreateOrder struct {
    repo OrderRepository
    pay  PaymentGateway
    log  *zap.Logger
}

func NewCreateOrder(r OrderRepository, p PaymentGateway, l *zap.Logger) CreateOrder {
    return CreateOrder{repo: r, pay: p, log: l}
}

type Input struct {
    UserID uuid.UUID
    Items  []ItemDTO
}

func (uc CreateOrder) Execute(ctx context.Context, in Input) (uuid.UUID, error) {
    order := NewOrder(in.UserID, in.Items) // фабрика в domain
    if err := uc.repo.WithTx(ctx, func(r OrderRepository) error {
        if err := r.Save(ctx, order); err != nil {
            return err
        }
        return uc.pay.Reserve(ctx, order.ID, order.Total())
    }); err != nil {
        return uuid.Nil, fmt.Errorf("create order: %w", err)
    }
    uc.log.Info("order created", zap.String("id", order.ID.String()))
    return order.ID, nil
}

Бизнес-решения тут концентрированы: транзакция, вызов платежки, лог. Ни JSON, ни http.Request.

HTTP-адаптер (/adapters/http/order_handler.go)

func (h Handler) createOrder(w http.ResponseWriter, r *http.Request) {
    var dto struct { Items []ItemDTO `json:"items"` }
    if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
        respondErr(w, http.StatusBadRequest, err)
        return
    }

    id, err := h.uc.Execute(r.Context(), app.Input{
        UserID: userIDFromCtx(r.Context()),
        Items:  dto.Items,
    })
    if err != nil {
        respondErr(w, statusFromErr(err), err)
        return
    }
    respondJSON(w, http.StatusCreated, map[string]string{"id": id.String()})
}

90 % кода — маршаллинг / статус-коды. Бизнес-правила остались нетронутыми.

Детали

Dependency Injection без фреймворка

В cmd/service/main.go можно собрать зависимости:

repo := postgres.NewOrderRepo(db)
pay  := stripe.NewGateway(httpClient)
uc   := app.NewCreateOrder(repo, pay, logger)
h    := http.NewHandler(uc)

srv := &http.Server{Handler: h, Addr: cfg.HTTPAddr}
log.Fatal(srv.ListenAndServe())

Не нужен Uber FX или Wire, но если проект растёт — подключаем генератор провайдеров, а use-case остаётся неизменным.

Тестируем use-case за 5 мс

func TestExecute_Success(t *testing.T) {
    repo := mocks.NewOrderRepo(t)
    repo.On("WithTx", mock.Anything, mock.Anything).
        Run(txFuncSuccess(repo)).Return(nil)
    pay := mocks.NewPaymentGateway(t)
    pay.On("Reserve", mock.Anything, mock.AnythingOfType("uuid.UUID"), mock.Anything).
        Return(nil)

    uc := app.NewCreateOrder(repo, pay, zaptest.NewLogger(t))
    _, err := uc.Execute(context.Background(), input())
    assert.NoError(t, err)
}

Мокаем интерфейсы — не нужен Postgres или Stripe. Юнит-тест бежит мгновенно, покрывает бизнес-ветки.

Расширяемость

Выходит заказ-реньюер: каждые 24 ч нужно проверять неоплаченные заказы и отменять. Пишем:

func RenewExpired(ctx context.Context, uc app.CancelExpired) {
    if err := uc.Execute(ctx); err != nil {
        log.Error("cancel job", err)
    }
}

Собрали пакет /adapters/cron, вытащили тот же use-case — 0 строк дублированной бизнес-логики.

Контраргумент

Главный упрёк — «ещё один слой, лишний код». Но на практике это 20–40 строк интерфейсов и конструкций, которые спасают, когда у вас больше двух входов (HTTP, Cron, Kafka). Вместо дублирования логики — единая точка оркестрации. Один раз написал use-case — используешь в трёх местах.

Про перформанс: вызов интерфейса в Go — это 15–20 наносекунд. Даже самая быстрая сериализация (тот же, JSON, protobuf) — это тысячи раз дольше. Если bottleneck у вас в абстракциях, а не в IO, то значит вы не туда смотрите.

Бойлерплейт легко гасится генерацией: mockery, wire, go generate — с ними вся рутинная обвязка пишется один раз и забывается.

Практическая мелочёвка, за которую вас похвалят

Observability. В каждый use-case инжектируйте trace.Tracer и метрики-функцию, буквально так:

type Metrics interface {
    IncOrdersCreated()
    ObserveLatency(start time.Time, op string)
}

type CreateOrder struct {
    repo OrderRepository
    pay  PaymentGateway
    mtr  Metrics
    trc  trace.Tracer
}

func (uc CreateOrder) Execute(ctx context.Context, in Input) (uuid.UUID, error) {
    ctx, span := uc.trc.Start(ctx, "create_order")
    defer span.End()
    defer uc.mtr.ObserveLatency(time.Now(), "create_order")

    // …
}

Теперь любой адаптер сам решает, что это будет — OpenTelemetry, Prometheus, Datadog. Use-case не меняется.

Ошибки как сигналы. В домене объявляйте sentinels:

var (
    ErrInventory = errors.New("no inventory")
    ErrPayment   = errors.New("payment declined")
)

А в use-case добавляйте контекст, ничего не меняя наружу:

return fmt.Errorf("reserve items: %w", domain.ErrInventory)

Мап-таблица из ошибок в HTTP-статусы лежит в adapters/http. gRPC-слой использует ту же карту, но переводит в codes.FailedPrecondition.

Параллелизм. Распараллелить сложный use-case легко — внутри Execute:

grp, ctx := errgroup.WithContext(ctx)

grp.Go(func() error {
    return uc.repo.Save(ctx, order)
})
grp.Go(func() error {
    return uc.pay.Reserve(ctx, order.ID, order.Total())
})

if err := grp.Wait(); err != nil {
    return uuid.Nil, err
}

Нет гонок: бизнес-операции всё ещё обёрнуты в транзакцию WithTx, а errgroup снимает головную боль с каналами.

Zero-downtime миграции. Меняем структуру Order? Сначала добавляем новое поле StatusHistory []StatusTransition и пишем доп. use-case BackfillHistory. Он идет через Cron-адаптер и безопасно мигрирует данные, не трогая HTTP-путь.


Делитесь своими кейсами в комментариях и задавайте вопросы.

Если вы проектируете архитектуру так, чтобы она жила дольше MVP — приглашаю на открытый вебинар 17 июня «Монолит или микросервисы?». Разберём, как принимать архитектурные решения без догм: когда монолит — благо, а когда — технический долг, как выходить из него с минимальными потерями и что учитывать до того, как проект начнёт расползаться по сервисам. Всё по делу, с примерами и без евангелизма.

Записаться на урок можно на странице курса "Software Architect".

А если вам интересно проверить свой уровень знаний для поступления на курс, пройдите вступительное тестирование.

Комментарии (0)