Разделение ответственности

Разделение ответственности (Separation of Concerns, SoC) означает, что часть системы отвечает за одну согласованную область решений и меняется по своим причинам. Граница не обязана совпадать с файлом, пакетом или «слоем»: её определяют правила, владение данными и сценарии изменения.

Зачем это на интервью

Принцип проверяют задачами вида «handler содержит валидацию, расчёт цены и SQL — что делать?». Важно показать не механическое разбиение на папки, а умение отделить HTTP-детали от прикладного сценария и инфраструктурную реализацию от правила бизнеса, сохранив простой путь выполнения.

Минимум для E4

  • Формулировать ответственность как причину изменения, а не как название технологии.
  • Отделять decoding, HTTP-коды и аутентификационные детали от use case.
  • Держать доменное правило рядом с данными и инвариантами, которые оно защищает.
  • Передавать зависимости явно и тестировать прикладной сценарий без сети и БД.

Рассмотрим создание заказа. Handler преобразует HTTP-запрос в команду, проверяет формат и выбирает ответ. Use case проверяет бизнес-инварианты: доступность товара, права клиента, переход статуса. Repository скрывает SQL и транзакционные детали. Это не запрет на вызовы между слоями, а способ не позволять изменению URL, драйвера БД или бизнес-правила затронуть весь код одновременно.

import "errors"
 
type CreateOrder struct {
    products ProductCatalog
    orders   OrderRepository
}
 
func (uc CreateOrder) Execute(ctx context.Context, customerID string, items []Item) (Order, error) {
    if len(items) == 0 {
        return Order{}, ErrEmptyOrder
    }
    if err := uc.products.Reserve(ctx, items); err != nil {
        return Order{}, err
    }
    order := NewOrder(customerID, items)
    if err := uc.orders.Save(ctx, order); err != nil {
        // Release должна быть идемпотентной; при ошибке её нужно повторить
        // через надёжную компенсацию и поднять алерт.
        return Order{}, errors.Join(err, uc.products.Release(ctx, items))
    }
    return order, nil
}

HTTP-handler может вызвать Execute, но CreateOrder не должен знать об http.Request, JSON или конкретном драйвере PostgreSQL. Здесь резервирование и сохранение не являются одной транзакцией, поэтому при ошибке Save требуется компенсация Release; для более строгой гарантии нужны общая транзакционная граница или durable workflow/outbox. Если цена ошибки вызова важна, бизнесовый тип ошибки переводится в HTTP-ответ на границе transport.

Углубление для E5/Senior

SoC полезно применять на нескольких масштабах. Внутри функции отделяйте вычисление от I/O. В сервисе выделяйте доменный сценарий, transport и адаптеры. Между сервисами границу проводите по владению данными, консистентности и автономности развёртывания, а не по каждой сущности ER-диаграммы.

Не создавайте «слой ради слоя». Тонкий CRUD-сервис с одним хранилищем может оставаться простым пакетом, пока не появляется независимая бизнес-вариативность. Напротив, если один пакет одновременно меняется из-за требований платежей, SQL-оптимизации и HTTP-версии API, формально маленький код уже имеет несколько ответственностей.

Граница требует контракта ошибок и транзакций. Use case должен определить, что атомарно и какие ошибки видит вызывающий код; repository не должен незаметно навязывать HTTP-статусы или retry-политику. Для распределённого процесса границы также определяют, где допустимы eventual consistency, outbox и компенсация.

Ключевые понятия

  • Причина изменения — независимое требование, из-за которого код нужно менять.
  • Transport — представление входа/выхода протокола: HTTP, gRPC, CLI, очередь.
  • Use case — прикладной сценарий, координирующий правила и зависимости.
  • Доменный инвариант — условие, которое должно оставаться истинным для корректного состояния.
  • Адаптер — код, переводящий внешний контракт в внутренний или обратно.
  • Граница транзакции — место, где определяется атомарность изменения и обработка сбоя.

Типовые вопросы

  1. Как отличить SoC от SRP?
    • SoC — широкое разделение разных областей решений; SRP обычно формулируют для компонента с одной причиной изменения. Они дополняют друг друга.
  2. Нужно ли handler всегда делать тонким?
    • Он должен владеть протоколом. Для простого endpoint допустима небольшая логика преобразования, но бизнес-правило, нужное другим входам, лучше вынести.
  3. Где должна жить валидация?
    • Синтаксис и ограничения транспорта — на границе; инварианты предметной области — в домене/use case. Одно правило может проверяться в обоих местах с разными целями.
  4. Почему repository не должен возвращать HTTP-ошибку?
    • Хранилище не знает протокол потребителя. Оно возвращает техническую или предметную ошибку, а transport выбирает её представление.
  5. Когда отдельный микросервис — плохая SoC?
    • Когда у частей нет независимого владения данными, жизненного цикла и потребности в отдельном масштабировании: сеть добавит отказов и контрактов без реальной автономности.
  6. Как проверить границу тестом?
    • Unit-тест use case подменяет порт in-memory fake и не поднимает HTTP или БД; отдельно тестируется адаптер и маппинг ошибок.

Практика

  • Рефакторите handler, в котором есть JSON, SQL и расчёт скидки. Критерий: прикладной сценарий принимает типизированную команду, а его unit-тест не импортирует net/http.
  • Опишите контракт OrderRepository: операции, ошибки и границу транзакции. Критерий: PostgreSQL-адаптер и in-memory fake проходят один набор сценариев use case.
  • Добавьте gRPC-вход к существующему сценарию. Критерий: бизнес-правила не дублируются, а HTTP и gRPC по-разному отображают ошибки только на своих границах.

Частые ошибки и ловушки

  • Называть пакет service и помещать туда всё, что не подошло остальным пакетам.
  • Выносить каждую строку в отдельный слой и усложнять чтение без независимой причины изменения.
  • Дублировать инвариант между handler и use case так, что каналы расходятся в поведении.
  • Давать инфраструктуре принимать доменные решения, например выбирать скидку по тексту ошибки драйвера.
  • Делить микросервисы по таблицам вместо бизнес-владения и требований к консистентности.

Связанные темы

Принципы проектирования · Связность и зацепление · Проектирование Go-приложений · System Design · Тестирование · Delivery

Источники

  • Edsger W. Dijkstra, On the role of scientific thought, о separation of concerns.
  • Robert C. Martin, Clean Architecture, главы о границах и зависимостях.
  • Eric Evans, Domain-Driven Design, главы о bounded context и модулях.
  • Документация Go: Organizing a Go module и Code Review Comments.