← назад к разделу

Когда приложение разбито на несколько сервисов, сразу возникают вопросы: как клиент находит нужный сервис? Кто проверяет токен — каждый сервис отдельно или кто-то один? Как постепенно переехать со старого монолита? Структурные паттерны — это готовые ответы на эти вопросы.

Разберём десять самых распространённых: от простых шлюзов до Service Mesh.

API Gateway

Представьте, что у вас в городе десяток ресторанов, но каждый по своему адресу. Вместо того чтобы запоминать все адреса, люди идут в один торговый центр — а там уже понятно, куда идти. API Gateway — это такой торговый центр для ваших сервисов.

Проблема. Клиент (мобильное приложение, браузер) не должен знать адреса всех сервисов внутри системы. Если каждый сервис сам проверяет токен, ставит лимиты на запросы и логирует — это одинаковый код в десяти местах.

Решение. Gateway принимает все входящие запросы и направляет их в нужный сервис. Сквозные задачи — аутентификация, ограничение числа запросов, CORS, логирование — делаются здесь один раз.

diagram

Пример маршрутизации на Go — стандартные net/http и httputil.ReverseProxy:

func newGateway() http.Handler {
	orderService := proxyTo("http://order-service:8080")
	userService := proxyTo("http://user-service:8080")

	orderLimiter := rate.NewLimiter(rate.Limit(10), 20) // 10 rps, всплеск до 20

	mux := http.NewServeMux()
	mux.Handle("/api/orders/", rateLimit(orderLimiter, http.StripPrefix("/api", orderService)))
	mux.Handle("/api/users/", http.StripPrefix("/api", userService))
	return mux
}

func proxyTo(rawURL string) http.Handler {
	target, _ := url.Parse(rawURL)
	return httputil.NewSingleHostReverseProxy(target)
}

func rateLimit(limiter *rate.Limiter, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			w.WriteHeader(http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Gateway проверяет JWT один раз и передаёт идентификатор пользователя в сервисы через заголовок — сервисам не нужно заниматься этим самостоятельно (JWT разбирается библиотекой golang-jwt/jwt):

type Claims struct {
	jwt.RegisteredClaims
	Roles []string `json:"roles"`
}

func authMiddleware(keyFunc jwt.Keyfunc, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		auth := r.Header.Get("Authorization")
		if !strings.HasPrefix(auth, "Bearer ") {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		claims := &Claims{}
		token, err := jwt.ParseWithClaims(strings.TrimPrefix(auth, "Bearer "), claims, keyFunc)
		if err != nil || !token.Valid {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		r.Header.Set("X-User-Id", claims.Subject)
		r.Header.Set("X-User-Roles", strings.Join(claims.Roles, ","))
		next.ServeHTTP(w, r)
	})
}

Когда нужен: много сервисов, нужна единая точка входа, сквозные задачи дублируются в каждом сервисе.

Когда не нужен: монолит или 1–2 сервиса — Gateway добавляет лишний сетевой переход без выгоды.

Gateway Routing

Проблема. Запросы нужно направлять к конкретному сервису в зависимости от URL, HTTP-метода или заголовков. Без явных правил маршрутизации невозможно понять, куда уходит каждый запрос.

Решение. Gateway Routing — это набор правил (предикатов): если URL начинается с /api/orders/, идёт в Order Service; если пришёл заголовок X-API-Version: v2, идёт в новую версию сервиса.

mux := http.NewServeMux()

// По пути
mux.Handle("/api/orders/", orderService)

// По заголовку — версионирование API
mux.Handle("/api/users/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	if r.Header.Get("X-API-Version") == "v2" {
		userServiceV2.ServeHTTP(w, r)
		return
	}
	userServiceV1.ServeHTTP(w, r)
}))

// Распределение трафика — 20% на новую версию
mux.Handle("/api/catalog/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	if rand.IntN(100) < 20 {
		catalogServiceV2.ServeHTTP(w, r)
		return
	}
	catalogServiceV1.ServeHTTP(w, r)
}))

Когда нужен: разные версии API в разных сервисах, постепенное переключение трафика на новую версию (canary).

Gateway Aggregation

Проблема. Страница заказа показывает данные из трёх сервисов: Order Service, User Service, Delivery Service. Если браузер делает три отдельных запроса — это три обращения по сети. На мобильных устройствах с медленным соединением это заметно.

Решение. Gateway принимает один запрос, параллельно опрашивает все нужные сервисы и собирает ответ в единый объект. Клиент получает данные за один круговой обход.

diagram
func (h *OrderDetailsHandler) GetOrderDetails(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	orderID := r.PathValue("orderId")

	var (
		wg       sync.WaitGroup
		order    *OrderDto
		user     *UserDto
		delivery *DeliveryDto
	)

	wg.Add(3)
	go func() {
		defer wg.Done()
		order, _ = fetchJSON[OrderDto](ctx, h.client,
			"http://order-service/orders/"+orderID)
	}()
	go func() {
		defer wg.Done()
		user, _ = fetchJSON[UserDto](ctx, h.client,
			"http://user-service/users/"+h.userIDFromOrder(ctx, orderID))
	}()
	go func() {
		defer wg.Done()
		delivery, _ = fetchJSON[DeliveryDto](ctx, h.client,
			"http://delivery-service/deliveries?orderId="+orderID)
	}()
	wg.Wait() // ошибка любого вызова → соответствующее поле останется пустым

	json.NewEncoder(w).Encode(OrderDetailsResponse{
		Order:    order,
		User:     user,
		Delivery: delivery,
	})
}

func fetchJSON[T any](ctx context.Context, client *http.Client, url string) (*T, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result T
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, err
	}
	return &result, nil
}

Когда нужен: клиенту нужны данные из 2–3 сервисов, агрегация простая, без бизнес-логики.

Когда не нужен: нужна сложная трансформация или фильтрация данных — это задача для BFF.

Backend for Frontend (BFF)

Проблема. У вас мобильное приложение, веб-админка и публичный сайт. Каждому нужен свой набор данных: мобильному — минимум (экономия трафика), админке — полный набор с журналом действий, публичному сайту — только открытые данные. Один общий API не может хорошо обслуживать всех.

Решение. Для каждого типа клиента создаётся отдельный BFF-сервис. Он сам обращается к доменным сервисам и возвращает только то, что нужно конкретному клиенту. Никакой бизнес-логики в BFF нет — только выбор и форматирование данных.

diagram

Mobile BFF возвращает только нужные поля:

// Mobile BFF — минимум данных
func (h *MobileOrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")

	order, err := h.orderClient.GetOrder(ctx, id)
	if err != nil {
		http.Error(w, "order service unavailable", http.StatusBadGateway)
		return
	}
	user, err := h.userClient.GetUser(ctx, order.UserID)
	if err != nil {
		http.Error(w, "user service unavailable", http.StatusBadGateway)
		return
	}

	json.NewEncoder(w).Encode(MobileOrderResponse{
		ID:          order.ID,
		Status:      order.Status,
		TotalAmount: order.TotalAmount,
		FirstName:   user.FirstName,
	})
}

Web BFF для администратора вытягивает данные из большего числа источников и добавляет поля, которых нет в мобильной версии.

Чем BFF отличается от API Gateway: Gateway — инфраструктурный компонент, маршрутизирует запросы. BFF — прикладной сервис, знает про потребности своего клиента и адаптирует данные. Они не конкурируют: Gateway стоит перед BFF.

Когда нужен: больше одного типа клиентов с разными потребностями в данных.

Когда не нужен: один тип клиента — достаточно API Gateway.

Gateway Offloading

Проблема. Каждый сервис самостоятельно настраивает SSL, проверяет токен, отдаёт заголовки безопасности, сжимает ответы. Это одинаковая работа в каждом сервисе.

Решение. Всё это выносится на Gateway. Внутренние сервисы работают по простому HTTP без SSL и без проверки токена — идентификатор пользователя они получают уже готовым из заголовка X-User-Id.

gateway := securityHeaders(          // заголовки безопасности
	rateLimit(ipLimiter,             // 100 rps, всплеск до 200
		authMiddleware(keyFunc,      // проверка JWT-подписи
			mux)))

func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("Strict-Transport-Security", "max-age=31536000")
		next.ServeHTTP(w, r)
	})
}

Внутренний сервис просто читает заголовок — он не занимается JWT:

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	userID := r.Header.Get("X-User-Id")

	order, err := h.orders.GetOrder(r.Context(), id, userID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	json.NewEncoder(w).Encode(order)
}

Что выносить на Gateway: SSL termination, проверка JWT-подписи, ограничение числа запросов, CORS, заголовки безопасности.

Что оставить в сервисе: авторизация (проверка прав на конкретный ресурс) — Gateway не знает бизнес-логику; валидация тела запроса — зависит от доменной модели.

Sidecar

Проблема. Сбор метрик, шифрование трафика, повторные попытки при ошибках — это нужно каждому сервису. Если сервисы написаны на разных языках, одну и ту же логику нужно писать на каждом из них.

Решение. Рядом с каждым сервисом запускается вспомогательный процесс (sidecar). Он берёт на себя инфраструктурные задачи, а основной сервис занимается только бизнес-логикой. В Kubernetes sidecar и основной контейнер живут в одном Pod-е и общаются через localhost.

diagram
spec:
  containers:
    - name: order-service
      image: order-service:1.0
      ports:
        - containerPort: 8080

    - name: envoy-sidecar
      image: envoyproxy/envoy:v1.28
      ports:
        - containerPort: 9901
        - containerPort: 15001
        - containerPort: 15006

Когда нужен: сервисы на разных языках, нужно единообразное шифрование трафика или повторные попытки на уровне сети.

Когда не нужен: все сервисы на одном языке — проще библиотека (sony/gobreaker для Go, tenacity для Python).

Service Mesh

Проблема. Sidecar решает задачу для одного сервиса. Но когда сервисов десятки — кто настраивает все эти прокси? Как управлять тем, кому с кем можно общаться? Как включить шифрование сразу для всей системы?

Решение. Service Mesh — инфраструктурный слой, который управляет всей сетью между сервисами. Он состоит из двух частей:

  • Data Plane — прокси (Envoy) в каждом Pod-е, через которые проходит весь трафик.
  • Control Plane — управляющий компонент (Istio, Linkerd), который раздаёт конфигурацию всем прокси.
diagram

Пример: Istio переключает 20% трафика на новую версию сервиса и настраивает повторные попытки:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: order-service
spec:
  hosts:
    - order-service
  http:
    - route:
        - destination:
            host: order-service
            subset: v1
          weight: 80
        - destination:
            host: order-service
            subset: v2
          weight: 20
      retries:
        attempts: 3
        perTryTimeout: 2s
        retryOn: 5xx,reset,connect-failure

API Gateway vs Service Mesh: Gateway обрабатывает трафик «снаружи внутрь» (клиент → система). Service Mesh управляет трафиком внутри системы (сервис → сервис).

Когда нужен: десятки сервисов, строгие требования к безопасности (шифрование между всеми сервисами), тонкое управление трафиком.

Когда не нужен: меньше 10 сервисов — сложность управления перевесит выгоду; достаточно библиотек вроде sony/gobreaker.

Strangler Fig

Проблема. Есть работающий монолит, который нужно перевести на микросервисы. Переписать всё сразу — рискованно и занимает год. Остановить новые функции на время переезда — невозможно.

Решение. Новая функциональность реализуется в микросервисах. Старая — постепенно переключается: трафик с монолита уходит в новые сервисы по мере их готовности. Монолит продолжает работать для всего, что ещё не переехало.

Название — по аналогии с растением фикус-душитель (Strangler Fig): оно обвивает дерево и со временем замещает его.

// Gateway на этапе переезда
mux := http.NewServeMux()
mux.Handle("/api/orders/", orderService) // новый Order Service
mux.Handle("/api/users/", userService)   // новый User Service

// Старый монолит — всё остальное: ServeMux отдаёт
// более длинному префиксу приоритет, /api/ ловит остаток
mux.Handle("/api/", monolith)

Переключение через флаг позволяет вернуться назад, если что-то пошло не так:

type OrderFacade struct {
	legacy        *LegacyOrderClient
	newService    *NewOrderClient
	useNewService bool // конфиг use-new-order-service, по умолчанию false
}

func (f *OrderFacade) GetOrder(ctx context.Context, id int64) (OrderDto, error) {
	if f.useNewService {
		return f.newService.GetOrder(ctx, id)
	}
	return f.legacy.GetOrder(ctx, id)
}

Типичный порядок переезда: выбрать наименее связанный модуль → создать микросервис с тем же API → переключить трафик через Gateway → сравнить ответы → убрать код из монолита.

Когда нужен: переезд с монолита без остановки разработки.

Когда не нужен: новый проект — начинайте с нужной архитектуры сразу; монолит, который работает хорошо и не мешает — не трогайте.

Anti-Corruption Layer (ACL)

Проблема. Ваш сервис интегрируется с внешней системой, у которой своя модель данных: поля называются txn_id, amount_cents, sts, статусы передаются кодами "S"/"F"/"P". Если напрямую использовать эти модели в бизнес-логике — весь код начнёт зависеть от чужих условностей. При замене внешней системы придётся переписывать половину сервиса.

Решение. Между сервисом и внешней системой ставится слой-переводчик. Он принимает «чужие» модели и преобразует их в понятные доменные объекты — и обратно при необходимости.

diagram
// Модель внешней системы
type ExternalPaymentResponse struct {
	TxnID       string `json:"txn_id"`
	AmountCents int    `json:"amount_cents"`
	Ccy         string `json:"ccy"`
	Sts         string `json:"sts"` // "S" = success, "F" = failed, "P" = pending
	CreatedTs   int64  `json:"created_ts"`
}

// Наша доменная модель — понятные имена
type Payment struct {
	TransactionID uuid.UUID
	Amount        Money
	Status        PaymentStatus
	CreatedAt     time.Time
}

// Переводчик — изолирует внешнюю модель от домена
func ToDomain(external ExternalPaymentResponse) (Payment, error) {
	transactionID, err := uuid.Parse(external.TxnID)
	if err != nil {
		return Payment{}, err
	}
	currency, err := CurrencyFromCode(external.Ccy)
	if err != nil {
		return Payment{}, err
	}
	status, err := mapStatus(external.Sts)
	if err != nil {
		return Payment{}, err
	}
	return Payment{
		TransactionID: transactionID,
		Amount:        Money{Cents: external.AmountCents, Currency: currency},
		Status:        status,
		CreatedAt:     time.Unix(external.CreatedTs, 0).UTC(),
	}, nil
}

func mapStatus(externalStatus string) (PaymentStatus, error) {
	switch externalStatus {
	case "S":
		return StatusSuccess, nil
	case "F":
		return StatusFailed, nil
	case "P":
		return StatusPending, nil
	default:
		return "", fmt.Errorf("unknown payment status: %q", externalStatus)
	}
}

Когда нужен: интеграция с внешним API или старой системой с чужой моделью данных; когда планируется замена внешней системы.

Когда не нужен: внешний API полностью совпадает с вашей моделью данных и замена не планируется.

Service Registry и Service Discovery

Проблема. Сервисы масштабируются динамически: сегодня 3 экземпляра Order Service, завтра 10. Экземпляры появляются и исчезают при деплоях, автомасштабировании, перезапусках. Прописать адреса вручную невозможно.

Решение. Service Registry — реестр, в котором каждый сервис регистрируется при запуске. Другие сервисы ищут адреса через этот реестр. Есть два подхода:

  • Client-Side Discovery — клиент сам спрашивает реестр (Consul, etcd) и выбирает экземпляр.
  • Server-Side Discovery — запрос проходит через балансировщик, который знает про реестр (Kubernetes Services).
diagram

С Consul клиент запрашивает адрес сервиса по имени — реестр сам возвращает живые экземпляры (hashicorp/consul/api):

func resolvePaymentService(consul *api.Client) (string, error) {
	services, _, err := consul.Health().Service("payment-service", "", true, nil)
	if err != nil {
		return "", err
	}
	if len(services) == 0 {
		return "", errors.New("no healthy payment-service instances")
	}
	svc := services[rand.IntN(len(services))].Service
	return fmt.Sprintf("http://%s:%d", svc.Address, svc.Port), nil
}

В Kubernetes service discovery встроен: каждый Service получает DNS-имя, и обращение http://payment-service:8080 автоматически попадает к одному из Pod-ов.

apiVersion: v1
kind: Service
metadata:
  name: payment-service
spec:
  selector:
    app: payment-service
  ports:
    - port: 8080

Когда что выбирать: Consul/etcd — если не используете Kubernetes или нужны дополнительные возможности реестра. Kubernetes DNS — если вы в Kubernetes, он встроен и ничего настраивать не нужно.

С чего начать

Если вы строите систему из нескольких сервисов:

  1. API Gateway + Gateway Routing — единая точка входа, маршрутизация по путям.
  2. Service Discovery — Kubernetes DNS, если вы в K8s; иначе Consul или etcd.
  3. Gateway Offloading — вынесите SSL и проверку токена на Gateway.

По мере роста:

  • BFF — когда появится второй тип клиентов.
  • Gateway Aggregation — когда клиентам нужны данные из нескольких сервисов за один запрос.
  • Anti-Corruption Layer — при интеграции со старой системой с чужой моделью данных.

Для больших систем:

  • Service Mesh — когда управление трафиком между десятками сервисов становится сложным.
  • Strangler Fig — когда нужно постепенно переехать с монолита.

Коротко

  • API Gateway — единая точка входа, которая берёт на себя auth, rate limiting, логирование.
  • Gateway Routing — правила маршрутизации: какой запрос идёт в какой сервис.
  • Gateway Aggregation — один запрос клиента → несколько параллельных вызовов → один ответ.
  • BFF — отдельный сервис-адаптер для каждого типа клиента (мобильный, веб, публичный).
  • Gateway Offloading — сквозные задачи (SSL, токены, заголовки) один раз на Gateway, а не в каждом сервисе.
  • Sidecar — вспомогательный процесс рядом с сервисом, берёт инфраструктурные задачи независимо от языка.
  • Service Mesh — управление всей сетью между сервисами через Control Plane и Envoy-прокси.
  • Strangler Fig — постепенный переезд с монолита: трафик переключается по частям, откат всегда возможен.
  • Anti-Corruption Layer — слой-переводчик между своей доменной моделью и чужим API.
  • Service Registry & Discovery — реестр живых экземпляров сервисов; в Kubernetes встроен.

Что почитать дальше