Олег Марков
Сегмент services - как проектировать services-segment в микросервисной архитектуре
Введение
Сегмент services (далее будем называть его services-segment) — это логический слой или часть системы, где живет бизнес-логика и прикладные микросервисы. Когда вы проектируете распределенную систему, вам важно понимать, как выделить этот сегмент, что в него входит, как сервисы общаются друг с другом и через какие контракты.
Смотрите, здесь мы разберем services-segment как отдельный уровень архитектуры:
- что обычно входит в сегмент services;
- как разделять ответственность между сервисами;
- как организовать взаимодействие;
- какие типовые паттерны применяются;
- как выглядит пример реализации простого services-segment;
- как связать services-segment с другими слоями (например, API-gateway, data-segment).
Я буду приводить примеры на условном стеке (REST, HTTP, JSON, иногда gRPC), но сами принципы можно использовать в любом языке и технологиях.
Что такое services-segment и зачем он нужен
Логика сегмента services
Services-segment — это слой, в котором:
- Инкапсулируется бизнес-логика.
- Сервисы реализуют свои публичные контракты.
- Выполняется оркестрация данных между клиентами и хранилищами.
- Применяются доменные инварианты и правила.
Обычно services-segment отделен:
- сверху — API-шлюзом, фронтендами, внешними интеграциями;
- снизу — сегментом данных (БД, кэши, очереди);
- по бокам — другими доменными сервисами.
Принцип границ ответственности
Давайте сразу зафиксируем ключевую идею: каждый сервис должен отвечать за четко очерченную область. Это может быть:
- доменная область (users-service, billing-service, orders-service);
- техническая функция (notification-service, mail-service, file-service);
- интеграция (payment-gateway-service).
Если границы размыты, вы быстро получите «расползание» логики, дублирование и циклические зависимости.
Типы сервисов внутри services-segment
Внутри services-segment вы чаще всего увидите такие типы:
- Доменные сервисы — реализуют бизнес-правила (order-service, catalog-service).
- Инфраструктурные сервисы — обертки над внешними системами (sms-service, payment-service).
- Оркестраторы / фасады — собирают цепочки операций из нескольких внутренних и внешних сервисов.
Хорошая практика — держать «ядро» домена в доменных сервисах и минимизировать бизнес-логику в инфраструктурных.
Архитектура services-segment
Слой сервисов в общей схеме
Представим упрощенную схему:
- Client (web, mobile, интеграции) → API Gateway / BFF → services-segment (наши микросервисы) → data-segment (БД, очереди, кэши, файловые хранилища).
Сегмент services:
- предоставляет API для gateway;
- общается с другими сервисами по внутренним протоколам (REST, gRPC, события);
- скрывает от клиентов детали хранения данных и внутренней структуры.
Варианты организации services-segment
В реальных системах services-segment может выглядеть по-разному:
- Один монолитный backend, логически разделенный на модули services.
- Набор независимых сервисов, развернутых отдельно (микросервисная архитектура).
- Гибрид, где есть несколько крупных сервисов (модульный монолит + микросервисы).
В статье дальше мы будем считать, что у вас набор отдельных микросервисов, но подходы легко переносятся на модульный монолит.
Проектирование сервисов внутри services-segment
Шаг 1. Определение доменов и bounded context
Давайте начнем с основной идеи: сначала определяются доменные области (bounded context), а затем под них выделяются сервисы.
Пример для e-commerce:
- Пользователи и аутентификация (auth, identity).
- Каталог товаров.
- Корзина.
- Заказы.
- Оплаты.
- Доставка.
Каждая область — кандидат на отдельный сервис или группу сервисов. Здесь важно:
- не дробить слишком рано;
- не складывать все в один «mega-service».
Шаг 2. Правила выделения сервисов
Обратите внимание на такие признаки, что нужен отдельный сервис:
- У области свой словарь и модель (User, Customer, Account могут быть разными сущностями в разных контекстах).
- Разные требования по масштабированию (catalog-service читает много, orders-service пишет меньше, но с транзакциями).
- Разные требования по доступности и SLA.
- Разные команды разработки.
Если вы чувствуете, что сервис начинает отвечать «за все подряд», — значит границы выбраны неправильно, попробуйте пересмотреть разбиение.
Шаг 3. Виды интерфейсов сервисов
Внутри services-segment сервисы предлагают:
- синхронные интерфейсы:
- HTTP REST;
- gRPC;
- асинхронные:
- события в брокере (Kafka, RabbitMQ, NATS);
- очередь задач.
Комбинация:
- внешним клиентам — REST;
- внутренним сервисам — gRPC и события.
Так вы уменьшаете задержки и повышаете устойчивость.
Контракты и модели данных в services-segment
API-контракты
Контракт сервиса — это описание того, что он делает и как с ним взаимодействовать:
- endpoint-ы (URL, методы);
- форматы запросов/ответов;
- коды ошибок;
- правила валидации.
Смотрите, я покажу вам простой пример контракта для orders-service в стиле OpenAPI (yaml):
paths:
/orders:
post:
summary: Create new order
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: Order created
content:
application/json:
schema:
$ref: '#/components/schemas/OrderResponse'
'400':
description: Validation error
Комментарии:
- Здесь мы явно описываем входные и выходные модели.
- Этот контракт будет основой для генерации клиентских SDK.
- Изменение контракта — изменение поведения сервиса, и его нужно версионировать.
Внутренние доменные модели
Важно разделять:
- внешние DTO (Data Transfer Objects) — то, что идет по сети;
- внутренние доменные модели — то, как сервис представляет объекты внутри.
Например, для заказа можно иметь:
- OrderDTO — то, что уходит наружу (минимум полей, стабильная структура);
- Order — доменная сущность с полями, нужными только внутри сервиса.
Давайте посмотрим пример кода на условном языке (похожем на Go):
// OrderDTO - модель, которая возвращается клиенту через API
type OrderDTO struct {
ID string `json:"id"` // Публичный идентификатор заказа
Status string `json:"status"` // Текущий статус
Total float64 `json:"total"` // Итоговая сумма
CreatedAt time.Time `json:"createdAt"` // Дата создания
}
// Order - внутренняя доменная модель
type Order struct {
ID string // Внутренний ID
UserID string // Владелец заказа
Items []OrderItem // Позиции заказа
Total float64 // Итоговая сумма
Status OrderStatus // Статус, как enum
Version int // Версия для оптимистичной блокировки
CreatedAt time.Time // Дата создания
UpdatedAt time.Time // Дата обновления
PaymentState PaymentState // Состояние оплаты
}
Комментарии к примеру:
- В DTO мы не показываем все внутренние поля.
- Доменная модель богаче и позволяет реализовывать бизнес-правила.
- Такое разделение помогает менять внутреннюю реализацию без ломки внешних контрактов.
Взаимодействие сервисов внутри services-segment
Синхронные вызовы
Самый понятный вариант: один сервис вызывает HTTP/gRPC метод другого и ждет ответ.
Плюсы:
- просто реализовать;
- легко отлаживать;
- привычно для большинства команд.
Минусы:
- жесткая связность по доступности (если один сервис упал — другие ждут);
- риск каскадных сбоев.
Давайте разберемся на примере:
- order-service создает заказ;
- для расчета стоимости доставки обращается к delivery-service.
// DeliveryClient - клиент для вызова delivery-service
type DeliveryClient interface {
Calculate(ctx context.Context, req DeliveryCalcRequest) (DeliveryCalcResponse, error)
}
// OrderService - доменный сервис работы с заказами
type OrderService struct {
deliveryClient DeliveryClient // Зависимость от delivery-service
}
// CreateOrder - пример метода создания заказа
func (s *OrderService) CreateOrder(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
// Сначала валидируем входные данные
if err := validateCreateOrder(cmd); err != nil {
return nil, err // Возвращаем ошибку валидации
}
// Вызываем delivery-service для расчета стоимости доставки
deliveryResp, err := s.deliveryClient.Calculate(ctx, DeliveryCalcRequest{
Destination: cmd.Destination,
Items: cmd.Items,
})
if err != nil {
return nil, fmt.Errorf("delivery calc failed: %w", err) // Оборачиваем ошибку
}
// Создаем доменную модель заказа с учетом результатов доставки
order := NewOrder(cmd, deliveryResp.Price)
// Сохраняем заказ в хранилище
if err := s.repo.Save(ctx, order); err != nil {
return nil, fmt.Errorf("save order failed: %w", err)
}
return order, nil
}
Как видите, здесь order-service напрямую зависит от delivery-service через клиент. Чтобы не усиливать связность, полезно:
- прятать сетевые детали за интерфейсами (DeliveryClient);
- использовать таймауты и circuit breaker;
- обрабатывать частичные сбои.
Асинхронное взаимодействие через события
Асинхронная модель уменьшает связность по времени и доступности:
- services публикуют события (OrderCreated, PaymentCompleted);
- другие сервисы подписываются и реагируют.
Например:
- order-service публикует событие OrderCreated;
- notification-service, analytics-service, fraud-service получают его и выполняют свои действия.
Покажу вам схему обработки в order-service:
// EventBus - абстракция над брокером сообщений
type EventBus interface {
Publish(ctx context.Context, topic string, event any) error
}
// OrderCreatedEvent - доменное событие для других сервисов
type OrderCreatedEvent struct {
ID string `json:"id"` // Идентификатор заказа
UserID string `json:"userId"` // Пользователь
Total float64 `json:"total"` // Сумма заказа
CreatedAt time.Time `json:"createdAt"` // Время создания
}
// PublishOrderCreated - публикация события после успешного создания заказа
func (s *OrderService) PublishOrderCreated(ctx context.Context, order *Order) error {
event := OrderCreatedEvent{
ID: order.ID,
UserID: order.UserID,
Total: order.Total,
CreatedAt: order.CreatedAt,
}
// Отправляем событие в брокер сообщений
return s.eventBus.Publish(ctx, "orders.created", event)
}
Комментарии:
- eventBus скрывает конкретную реализацию (Kafka, RabbitMQ и т.д.).
- Сервисы не знают друг о друге напрямую, только о типе события.
- Такой подход хорошо масштабируется, но усложняет трассировку и отладку.
Смешанная модель
На практике вы почти всегда комбинируете:
- синхронные вызовы — там, где нужен немедленный результат;
- события — там, где можно обработать последствия позже.
Например:
- заказ создается синхронно (клиент ждет ID);
- уведомления, логирование, аналитика — асинхронно через события.
Структура кода микросервиса в services-segment
Логические слои внутри одного сервиса
Один сервис внутри services-segment часто строят по слоям:
- transport / handlers — HTTP/gRPC контроллеры;
- service / use case — бизнес-операции;
- repository / storage — доступ к БД;
- clients — HTTP/gRPC клиенты к другим сервисам;
- events / messaging — работа с брокером.
Давайте посмотрим пример структуры:
/orders-service
/cmd
/orders-api // точка входа API-сервиса
/internal
/api // HTTP-обработчики
/service // бизнес-логика
/repository // работа с БД
/client // клиенты внешних сервисов
/events // публикация и обработка событий
/config // конфигурация
Пример: обработчик HTTP в orders-service
Теперь вы увидите, как это выглядит в коде: от HTTP-слоя до доменного сервиса.
// OrderHandler - HTTP-обработчик для работы с заказами
type OrderHandler struct {
svc *OrderService // Внедренный сервис с бизнес-логикой
}
// CreateOrderHandler - обработчик POST /orders
func (h *OrderHandler) CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateOrderRequest
// Декодируем JSON-запрос
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Возвращаем ошибку 400, если запрос некорректный
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
// Преобразуем запрос в команду бизнес-уровня
cmd := CreateOrderCommand{
UserID: req.UserID,
Items: mapItems(req.Items),
}
// Вызываем бизнес-операцию
order, err := h.svc.CreateOrder(ctx, cmd)
if err != nil {
// Обрабатываем ошибки доменного уровня
handleCreateOrderError(w, err)
return
}
// Готовим DTO для ответа
resp := OrderDTO{
ID: order.ID,
Status: string(order.Status),
Total: order.Total,
CreatedAt: order.CreatedAt,
}
// Устанавливаем заголовок и код ответа
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
// Отправляем JSON-ответ
_ = json.NewEncoder(w).Encode(resp)
}
Здесь я размещаю пример, чтобы вам было проще увидеть прохождение запроса:
- HTTP-слой ничего не знает про БД или другие сервисы;
- он работает только через OrderService;
- все бизнес-правила — в слое service.
Надежность и устойчивость services-segment
Таймауты, ретраи и circuit breaker
Когда сервисы внутри services-segment вызывают друг друга, важно защищаться от:
- долгих ответов;
- временных сбоев;
- частично недоступных сервисов.
Основные приемы:
- ограничивать время запроса таймаутом;
- повторять запросы (retry) при временных ошибках;
- отключать проблемный сервис (circuit breaker) на время.
Давайте посмотрим, как можно организовать вызов другого сервиса с таймаутом:
// CallWithTimeout - пример обертки над запросом к другому сервису
func CallWithTimeout(parentCtx context.Context, op func(ctx context.Context) error) error {
// Создаем контекст с таймаутом 2 секунды
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel() // Всегда освобождаем ресурсы контекста
// Вызываем операцию с ограничением по времени
if err := op(ctx); err != nil {
// Если произошла ошибка - возвращаем ее вызывающему коду
return err
}
return nil
}
Комментарии:
- таймауты лучше конфигурировать, а не хардкодить;
- разные операции могут требовать разного времени.
Идемпотентность операций
Когда вы используете retry, важно, чтобы операции были идемпотентными:
- повторный вызов с теми же параметрами не должен приводить к «дублированию» результата.
Например:
- создание заказа по одному и тому же externalId не должно создавать дубликаты.
Один из способов — использовать идемпотентные ключи и проверки в БД. Давайте разберемся на примере:
-- Уникальный индекс по внешнему идентификатору
CREATE UNIQUE INDEX idx_orders_external_id ON orders (external_id);
// CreateOrder - пример идемпотентного создания заказа
func (r *OrderRepository) CreateOrder(ctx context.Context, o *Order) error {
// Пытаемся вставить запись в БД
_, err := r.db.ExecContext(ctx, `
INSERT INTO orders (id, external_id, user_id, total)
VALUES ($1, $2, $3, $4)
`, o.ID, o.ExternalID, o.UserID, o.Total)
if isUniqueViolation(err) {
// Если запись уже существует - считаем, что заказ уже создан
return ErrOrderAlreadyExists
}
return err
}
Комментарии:
- если клиент повторит запрос с тем же externalId, вы получите ту же сущность;
- это снижает риск дублирующихся операций при сбоях сети.
Версионирование и эволюция services-segment
Зачем версионировать сервисы
Сервисы внутри services-segment живут долго, и их контракты меняются. Без версионирования вы рискуете:
- ломать клиентов при каждом изменении;
- усложнить внедрение новых функций;
- получить зависимость типа «все должны обновиться одновременно».
Хорошая практика:
- добавлять новые поля в ответ обратно совместимо;
- делать новые endpoint-ы или новые версии API при ломающих изменениях.
Подходы к версионированию
Чаще всего применяют:
- версию в URL:
- /api/v1/orders
- /api/v2/orders
- версию в заголовке:
- X-API-Version: 2
или
- отдельные gRPC пакеты:
- orders.v1
- orders.v2
Если вы меняете:
- структуру данных;
- поведение по умолчанию;
- семантику полей;
то логичнее делать новую версию и поддерживать старую какое-то время.
Совместное существование версий
На практике в services-segment часто живут сразу несколько версий одного сервиса или контракта.
Например:
- orders-service обрабатывает:
- /api/v1/orders — старая модель статусов;
- /api/v2/orders — новая модель с более детальной шкалой.
Внутри доменного слоя вы можете иметь одну расширенную модель и адаптеры к старому и новому формату DTO. Это снижает дублирование логики.
Обеспечение согласованности данных между сервисами
Локальные транзакции против распределенных
Каждый сервис в services-segment владеет своей БД. Это значит:
- локальные транзакции есть;
- распределенных транзакций (2PC) лучше избегать.
Вместо распределенных транзакций чаще используют:
- паттерн Sagas (оркестратор или хореография);
- eventual consistency — итоговая согласованность.
Пример сценария с заказами:
- order-service создает заказ в статусе Pending.
- payment-service пытается списать деньги.
- если оплата успешна — заказ переводится в статус Paid.
- если нет — в Canceled.
Между этими шагами может пройти время, но система в итоге придет к согласованному состоянию.
Паттерн Saga
Давайте посмотрим, как можно реализовать простую сагу оркестратором в отдельном сервисе или модуле:
// OrderSaga - оркестратор процесса создания и оплаты заказа
type OrderSaga struct {
orders OrderClient // Клиент order-service
payment PaymentClient // Клиент payment-service
}
// CreateAndPay - запускает сагу
func (s *OrderSaga) CreateAndPay(ctx context.Context, req CreateAndPayRequest) error {
// Шаг 1 - создаем заказ
order, err := s.orders.Create(ctx, req.Order)
if err != nil {
// Если заказ не создался - останавливаемся
return err
}
// Шаг 2 - пытаемся списать деньги
if err := s.payment.Charge(ctx, PaymentRequest{
OrderID: order.ID,
Amount: order.Total,
}); err != nil {
// Компенсирующее действие - отмена заказа
_ = s.orders.Cancel(ctx, order.ID)
return err
}
// Шаг 3 - переводим заказ в статус оплаченного
return s.orders.MarkPaid(ctx, order.ID)
}
Комментарии:
- оркестратор знает последовательность шагов;
- при ошибках он выполняет компенсирующие действия;
- эта логика может жить либо в отдельном сервисе, либо в доменном сервисе.
Об observability и операционном аспекте services-segment
Логирование
Каждый сервис должен:
- логировать ключевые бизнес-события;
- логировать технические ошибки;
- использовать корреляционные ID для трассировки сквозных запросов.
Пример:
// WithRequestID - мидлварь для установки корреляционного ID
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Читаем или генерируем request-id
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.New().String()
}
// Кладем ID в контекст
ctx := context.WithValue(r.Context(), requestIDKey, reqID)
// Передаем дальше по цепочке
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Комментарии:
- все сервисы внутри services-segment должны поддерживать единый формат логов;
- тогда вы сможете искать по одному request-id в распределенной системе.
Метрики и трассировка
Чтобы понимать, что происходит внутри services-segment, важно:
- собирать метрики (latency, error rate, throughput);
- использовать распределенную трассировку (OpenTelemetry, Jaeger, Zipkin).
Так вы сможете:
- найти медленные запросы;
- увидеть цепочку вызовов между сервисами;
- быстрее диагностировать проблемы.
Связь services-segment с другими сегментами
Связь с API Gateway / BFF
Чаще всего:
- клиент (web/mobile) общается с BFF (Backend For Frontend) или API Gateway;
- gateway маршрутизирует трафик к нужным сервисам из services-segment.
Задача services-segment:
- предоставить чистые, достаточно мелкие операции;
- не думать о специфике конкретного фронтенда.
Задача BFF/gateway:
- агрегировать данные из нескольких сервисов для удобства фронта;
- заботиться о аутентификации/авторизации на границе.
Связь с data-segment
Сервисы внутри services-segment:
- напрямую работают с своей БД/кэшем;
- могут использовать общий брокер сообщений;
- стараются не шарить одну БД между несколькими сервисами.
Важно правило: каждый сервис владеет своей схемой данных. Если другой сервису нужны данные, он:
- либо вызывает API владельца данных;
- либо подписывается на его события и держит локальную проекцию.
Так вы избегаете плотной связности на уровне БД.
Заключение
Services-segment — это центральный слой вашей архитектуры, в котором сосредоточена бизнес-логика. Правильно спроектированный services-segment помогает:
- разделить ответственность между независимыми сервисами;
- упростить сопровождение и развитие системы;
- повысить устойчивость и масштабируемость.
Ключевые мысли, которые стоит закрепить:
- Сначала думайте о домене и границах контекстов, а уже затем о сервисах.
- Старайтесь делать сервисы автономными владельцами своих данных.
- Для взаимодействия комбинируйте синхронные вызовы и асинхронные события.
- Вводите четкие API-контракты и версионирование.
- Закладывайте надежность (таймауты, ретраи, идемпотентность, саги).
- Не забывайте про наблюдаемость: логи, метрики, трассировку.
Если вы будете рассматривать services-segment как четко выделенный архитектурный уровень, а не просто «набор бэкэнд-сервисов», у вас появится понятная структура системы, которую легче масштабировать и развивать.
Частозадаваемые технические вопросы по теме и ответы
Как ограничить «чрезмерный» рост количества сервисов в services-segment
Используйте явные критерии выделения сервисов: отдельный bounded context, отдельная команда, отличные SLA или масштабирование. Периодически проводите ревью архитектуры и объединяйте слишком мелкие сервисы в более крупные, если они сильно связаны и часто меняются вместе.
Как выбрать между REST и gRPC для внутренних сервисов
Для внешних клиентов обычно удобнее REST. Для внутренних вызовов gRPC дает меньшую задержку и строгую типизацию. Хорошая стратегия — публичные интерфейсы строить на REST, а внутренние — на gRPC, используя генерацию клиентов из proto.
Что делать, если нужен общий поиск по данным из разных сервисов
Не тяните данные напрямую из БД разных сервисов. Вместо этого: 1) сервисы публикуют события об изменениях; 2) отдельный search-service подписывается на события и строит индекс (например, в Elasticsearch); 3) клиенты обращаются к search-service, а не к нескольким БД.
Как синхронизировать схемы сообщений между сервисами
Храните схемы событий в едином репозитории схем (schema registry). Используйте формат с явной схемой (Avro, Protobuf). При изменениях добавляйте новые поля опционально, избегая ломающих изменений, либо выпускайте новую версию события с другим типом или топиком.
Как организовать локальную разработку с большим количеством сервисов
Чаще всего: 1) запускают только изменяемый сервис локально; 2) остальные сервисы заменяют mock-ами или тестовыми контейнерами; 3) используют docker-compose для минимального набора зависимостей (БД, брокер, 1–2 критичных сервиса); 4) для сложных сценариев — отдельный «sandbox»-стенд, максимально похожий на прод.