Application Layer на примере Go — зачем он нужен, если уже есть сервисы?

от автора

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

Сегодня разберёмся, зачем 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/