Привет, Хабр!
Сегодня разберёмся, зачем 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".
А если вам интересно проверить свой уровень знаний для поступления на курс, пройдите вступительное тестирование.