Привет, Хабр!
Сегодня разберёмся, зачем 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».
А если вам интересно проверить свой уровень знаний для поступления на курс, пройдите вступительное тестирование.
ссылка на оригинал статьи https://habr.com/ru/articles/913532/
Добавить комментарий