Содержание урока · 13 разделов
- Service-слой: что это и что ему запрещено
- Интерфейс сервиса: контракт домена
- Sentinel-ошибки: словарь домена
- Зависимости: конструкторы вместо глобального состояния
- Service middleware: декораторы вокруг интерфейса
- Тесты логики без транспорта: самый быстрый тест в проекте
- Stub Repository: тест с зависимостью
- Что кладут в service-слой, а что нет
- Чеклист чистого сервиса
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Service-слой: что это и что ему запрещено#
Открой официальную FAQ — она требует от service-слоя чистоты по канонам Clean Architecture и Hexagonal Architecture, и это не украшение, а рабочее правило. Сервис — это интерфейс и его реализации: здесь принимаются бизнес-решения, валидируются доменные правила, считается результат. Запрещено транспортное: никаких *http.Request, gRPC-кодов, json-тегов и логов уровня «получен POST». Запрещено и инфраструктурное по мелочи: глобальные синглтоны, чтение env в глубине метода, тайные обращения к пакету os — всё это зависимости, и им место в конструкторе. Разрешено: работа с доменными моделями, вызовы других сервисов и интерфейсов-хранилищ, sentinel-ошибки домена. Проверка на честность простая: если файл с сервисом компилируется без единого транспортного импорта и его можно собрать в тесте одной строкой — слой чистый. Именно поэтому go-kit сервис называют самым скучным и самым дорогим слоем курса: скука — это дисциплина, а дисциплина — это возможность менять транспорт и хранилище, не трогая бизнес.
Интерфейс сервиса: контракт домена#
Всё начинается с интерфейса — в go kit сервис по определению является интерфейсом, а не структурой. Смотри на stringsvc: StringService объявляет два метода, Uppercase и Count, и в сигнатурах — только context.Context и доменные типы. Урок 10 продвинутого курса учил: интерфейсы маленькие и объявляются у потребителя; здесь потребитель — адаптеры endpoint'ов и декораторы, поэтому интерфейс описывает ровно те операции, что видит наружу. Обрати внимание на ctx в каждом методе: даже если сейчас контекст не нужен, он появится, когда сервис подрастёт и начнёт звать базу с дедлайном; заложи его сразу, чтобы не ломать сигнатуры. Имя интерфейса — по домену, а не по слою: StringService, ProfilesService, OrdersService. И не плодь гигантов: интерфейс на десять методов — сигнал, что сервис просится разделить. Первым уроком курса мы уже видели этот интерфейс в func main — сегодня он получает тело и начинает работать.
StringService: интерфейс и реализация
GOimport (
"context"
"strings"
"unicode/utf8"
)
// Интерфейс объявляет домен, а не транспорт.
type StringService interface {
Uppercase(ctx context.Context, s string) (string, error)
Count(ctx context.Context, s string) int
}
// Конкретная реализация: только бизнес, ничего про HTTP.
type stringService struct{}
func (stringService) Uppercase(_ context.Context, s string) (string, error) {
if s == "" {
return "", ErrEmpty
}
return strings.ToUpper(s), nil
}
func (stringService) Count(_ context.Context, s string) int {
return utf8.RuneCountInString(s)
}Это канонический stringsvc. Реализация не хранит состояния, поэтому приёмник объявлен на типе значения — struct{}. Uppercase возвращает ошибку, Count — нет: контракт домена отражает реальность, а не симметрию. utf8.RuneCountInString считает руны, а не байты — строки «ёж» и «yo» дадут одинаковую двойку.
Sentinel-ошибки: словарь домена#
Бизнес-отказы надо называть, и для этого в Go есть sentinel-ошибки: значения уровня пакета, созданные errors.New. stringsvc объявляет var ErrEmpty = errors.New("empty string") — сервис возвращает её, когда нечего аперкейсить. Три причины держать sentinel-ошибки. Первая — договорённость: вызывающий код может проверить errors.Is(err, ErrEmpty) и отреагировать точно, не парсингом строк. Вторая — перевозимость: ошибка едет в Err-поле response-структуры через Failer (урок 02), и транспорт на уроке 04 превратит её в HTTP-статус через таблицу codeFrom — sentinel сработает и там. Третья — документация: список var Err... в начале файла сервиса читается как меню возможных отказов. Правила hygiene: sentinel-ошибки экспортируются только если их проверяет чужой код; текст пишется строчными, без точки; не плодить по одной на каждый if — если ошибка ни для кого не различима, обобщённой хватит. И никогда не оборачивай sentinel строкой до errors.Is — сравнение по значению умирает при fmt.Errorf без %w.
ErrEmpty: sentinel уровня пакета
GOimport "errors"
// Меню бизнес-отказов сервиса. errors.Is умеет их находить.
var ErrEmpty = errors.New("empty string")
// Использование в тесте:
// _, err := svc.Uppercase(ctx, "")
// if !errors.Is(err, ErrEmpty) { t.Errorf("хотели ErrEmpty") }Один sentinel на осмысленный бизнес-случай. В stringsvc их ровно один — не засоряй словарь. Далее по курсу таблица соответствий sentinel → HTTP-статус (codeFrom) ляжет в транспортный слой, а сам сервис про статусы не узнает никогда.
Зависимости: конструкторы вместо глобального состояния#
Растущий сервис тянет за собой хранилище, и вот тут принимается архитектурное решение на годы. Правило go kit: зависимости объявляются интерфейсами и передаются конструктором. Хранилище — интерфейс Repository, объявленный у потребителя, то есть сервисом; конкретная реализация — память, Postgres, что угодно — пишется отдельно и приходит снаружи. Конструктор NewService принимает интерфейс и возвращает интерфейс сервиса: main собирает граф, тест подсовывает заглушку, никто не лезет в глобальные переменные. Это ровно та честность зависимостей, о которой говорил первый урок: если компоненту нужен логгер или репозиторий — он получает их параметром. Сравни с типовым gin-проектом, где хендлер сам открывает *gorm.DB из глобалки: там тест требует живую базу, а здесь — строку кода. Отдельное правило про nil: конструктор обязан паниковать или возвращать ошибку на nil-зависимости, потому что nil-репозиторий взорвётся в рантайме на первом запросе — лучше громко упасть на старте.
Сервис с зависимостью: ProfilesService
GO// Контракт хранилища объявляет потребитель — сервис, а не наоборот.
type Repository interface {
Save(ctx context.Context, id string, p Profile) error
}
// Сервису нужны данные — зависимость приходит конструктором.
type profilesService struct {
repo Repository
}
func NewProfilesService(r Repository) ProfilesService {
if r == nil {
panic("NewProfilesService: nil Repository")
}
return profilesService{repo: r}
}
func (s profilesService) PostProfile(ctx context.Context, p Profile) (string, error) {
// бизнес-валидация доменом, затем — хранилище
id := newID()
if err := s.repo.Save(ctx, id, p); err != nil {
return "", err // инфраструктурный сбой — через err наверх
}
return id, nil
}Профили из примера первого урока: сервис получает Repository интерфейсом и ничего не знает, память это или база. Сбой хранилища возвращается как error — по инфраструктурному каналу, а бизнес-отказы («email занят») пошли бы в response-структуру через Failer. Паника на nil — сознательная: граф зависимостей должен падать при сборке, не в проде.
Service middleware: декораторы вокруг интерфейса#
Первый урок обещал вернуться к service middleware — второй вид обёрток в go kit. Endpoint middleware оборачивает функцию; service middleware оборачивает интерфейс сервиса: type LoggingMiddleware func(StringService) StringService. Каноническая реализация — встраивание: структура loggingMiddleware включает StringService и переопределяет только нужные методы, вызывая base и дописывая поведение вокруг. Это декоратор в чистом виде: обёртка реализует тот же интерфейс, поэтому main может навесить хоть пять штук подряд, и тип не изменится — svc остаётся StringService. Что уносим в декораторы: логирование на уровне домена, метрики бизнес-событий, кэш, ретраи к хранилищу. Что остаётся в реализации: сама бизнес-логика. Разница с endpoint-обёртками практическая: декоратор видит доменные аргументы («в Uppercase передали пустую строку»), а endpoint-обёртка — структуры транспорта; логи уровня бизнеса делаются сервисными middleware, логи уровня запроса — endpoint'ными. В уроке 15 разложим их по полочкам, сегодня — первый рабочий образец.
LoggingMiddleware: первый декоратор
GOimport (
"context"
"time"
"github.com/go-kit/log" // kitlog — актуальный модуль
)
// Декоратор: реализует тот же интерфейс StringService.
type LoggingMiddleware func(StringService) StringService
func LoggingMiddleware(logger log.Logger) LoggingMiddleware {
return func(next StringService) StringService {
return loggingMiddleware{next, logger}
}
}
type loggingMiddleware struct {
StringService // встраивание: остальные методы не тронуты
logger log.Logger
}
func (mw loggingMiddleware) Uppercase(ctx context.Context, s string) (output string, err error) {
defer func(begin time.Time) {
mw.logger.Log("method", "uppercase", "input", s,
"output", output, "err", err, "took", time.Since(begin))
}(time.Now())
output, err = mw.StringService.Uppercase(ctx, s)
return
}Канон из stringsvc: именованные результаты output и err видны в defer, замер латентности — как в gin-middleware, только на домене. Встраивание StringService даёт Count бесплатно — декоратор переопределяет один метод, остальные проксируются. Сборка в main: svc = LoggingMiddleware(logger)(svc) — сигнатура не изменилась, endpoint-адаптеры из урока 02 продолжают работать без правок.
Тесты логики без транспорта: самый быстрый тест в проекте#
Вот зачем всё затевалось. Сервис — интерфейс без транспорта, значит тест собирает его одной строкой: svc := stringService{} или NewService(stubRepo). Никакого HTTP-сервера, ни JSON, ни портов — тест гоняется в микросекунды и не знает flaky. Форма — table-driven, штатный стиль Go: срез кейсов с именами, цикл по ним и t.Run на каждый; упавший кейс называет себя сам. Что покрывать: счастливый путь, граничные значения, sentinel-ошибки через errors.Is. Что НЕ покрывать на этом уровне: декодирование JSON (это транспорт, уроки 04 и 16), статусы HTTP, заголовки. Практика команд: бизнес-тесты — десятки кейсов на метод и прогон на каждый коммит; транспортные — несколько дымовых. Если тесту сервиса нужна живая база — это красный флаг: либо тест транспортный по духу, либо хранилище прячется за интерфейсом, и тогда заглушка в двадцать строк решает вопрос. На уроке 16 добавим тесты endpoint'ов и транспорта — но скелет надёжности строится здесь.
Table-driven тесты StringService
GOfunc TestUppercase(t *testing.T) {
svc := stringService{}
cases := []struct {
name, in, want string
}{
{"обычная строка", "hello, world", "HELLO, WORLD"},
{"уже в верхнем регистре", "GO", "GO"},
{"с кириллицей", "привет", "ПРИВЕТ"},
{"одна буква", "a", "A"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := svc.Uppercase(context.Background(), c.in)
if err != nil {
t.Fatalf("не ждали ошибку: %v", err)
}
if got != c.want {
t.Errorf("Uppercase(%q) = %q, хочу %q", c.in, got, c.want)
}
})
}
}
func TestUppercaseEmpty(t *testing.T) {
_, err := stringService{}.Uppercase(context.Background(), "")
if !errors.Is(err, ErrEmpty) {
t.Errorf("хотели ErrEmpty, получили %v", err)
}
}Сервис собран прямо в тесте — ни транспорта, ни фикстур. Первый тест — table-driven с именованными кейсами: добавление проверки — одна строка в срезе. Второй — sentinel: errors.Is сверяет по значению, поэтому любая обёртка ошибки через fmt.Errorf с %w сохранит совместимость. Прогон: go test ./service/... — доли секунды.
Stub Repository: тест с зависимостью#
Как только у сервиса появились зависимости, тест расщепляется: логика тестируется с заглушкой хранилища. Stub — минимальная реализация интерфейса Repository, достаточная для кейса: сохранить значение в карту, вернуть его по Get, а в режиме «злобной заглушки» — всегда отдавать ошибку, чтобы проверить ветку инфраструктурного сбоя. Обрати внимание, где объявлен stub: в тестовом файле, рядом с тестом, а не в общем пакете — интерфейс маленький, поэтому заглушка компактная; если Stub разросся, это сигнал, что интерфейс потолстел. Сценарий теста PostProfile: подсовываем memory-заглушку, зовём метод, проверяем, что id вернулся и Save действительно вызван с тем же значением; затем злобный stub — и убеждаемся, что ошибка хранилища прошла наверх по каналу err, не превратившись в бизнес-ответ. Так тестируются все зависимости курса: Repository, кэш, очередной клиент. Заметь дивиденд канонической сборки: декоратор LoggingMiddleware в тесте не нужен — логи проверяются отдельно, а можно наоборот обернуть сервис декоратором и проверить, что лог записан; обе проверки чистые, потому что всё через интерфейсы.
Что кладут в service-слой, а что нет#
| Элемент | Вердикт | Комментарий |
|---|---|---|
| Бизнес-правила и валидация домена | да | Uppercase, проверка профиля, подсчёт стоимости |
| Sentinel-ошибки домена | да | ErrEmpty, ErrNotFound — меню бизнес-отказов |
| Контекст ctx в сигнатурах | да | дедлайны и отмена едут вниз до Repository |
| json-теги и *http.Request | нет | транспорт остаётся в слое транспорта (урок 04) |
| SQL, Redis-клиенты, детали хранилища | нет | за интерфейсом Repository, реализации — снаружи |
| Логирование в теле методов | осторожно | лучше декоратором LoggingMiddleware — логи снимаются одной строкой |
| Глобальные переменные и env в методах | нет | зависимости — через конструктор, сборка — в main |
Чеклист чистого сервиса#
- Интерфейс объявлен у потребителя и описывает домен, а не транспорт.
- Один файл — один сервис; sentinel-ошибки видны в начале файла.
- Зависимости — интерфейсы, полученные конструктором; nil-зависимость паникует на старте.
- Бизнес-отказ — sentinel; инфраструктурный сбой — error наверх без маскарада.
- Table-driven тесты собирают сервис строкой и не знают про HTTP.
- Декораторы (логи, метрики) — service middleware, тело методов не трогают.
Пакет с StringService не импортирует ни go-kit, ни net/http — только стандартную библиотеку. Значит, тот же файл берётся в проект на gin, на echo или в чистый net/http без единой правки: адаптеры и декораторы переписываются по месту, а бизнес едет как есть. Такое свойство называют слоем сервиса без обязательств — и это лучший ответ на вопрос «не привяжет ли меня go-kit».
Код урока собирается обычным go test — сторонний модуль нужен только для LoggingMiddleware (github.com/go-kit/log), в песочнице курса он не исполняется, поэтому интерактивы построены на чтении кода. Локально прогони тесты урока: table-driven набор из четырёх кейсов проходит быстрее, чем открывается терминал.
Где объявляют интерфейс Repository, который нужен сервису профилей?
Сигнатура service middleware для StringService — какая?
Почему тесты сервиса называют самым быстрым тестом проекта?
Руны или байты: что вернёт Count?
Прочитайте код и выберите, что он выведет:
svc := stringService{}
n := svc.Count(context.Background(), "ёж")Два декоратора: какая запись в логе первая?
Прочитайте код и выберите, что он выведет:
var svc StringService = stringService{}
svc = LoggingMiddleware(logger)(svc) // логирование
svc = MetricsMiddleware(logger)(svc) // метрики поверх
svc.Uppercase(ctx, "go") // что появится в логе раньше всего?Восстанови конструктор с зависимостью
Собери сервис с Repository. Пропуски — по порядку: тип контекста, имя поля зависимости, имя структуры-реализации.
Собери table-driven тест
Расставь строки TestUppercase в правильном порядке: от объявления теста до закрывающих скобок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
Домен готов и проверен — сервис не знает, как его вызывают, и это правильная позиция. Осталась последняя стена между бизнесом и внешним миром: урок 04-http-transport собирает kithttp.NewServer, декодеры и энкодеры, а заодно раскладывает ошибки транспорта по HTTP-статусам. Для разогрева вернись к уроку 02-endpoint — там мы строили адаптеры, которые сейчас заработают по-настоящему; общая карта слоёв — в уроке 01-filosofiya, программа всего маршрута — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Service — интерфейс и реализации с чистым доменом: ни HTTP, ни json, ни транспортных импортов.
- Интерфейс объявляет потребитель; операции видят только context.Context и доменные типы.
- Бизнес-отказы — sentinel-ошибки уровня пакета, проверяются errors.Is, едут через Failer в транспорт.
- Зависимости — интерфейсы через конструктор; nil-зависимость паникует при сборке, а не в проде.
- Service middleware — func(Service) Service: декоратор со встраиванием, логи и метрики уровня домена.
- Table-driven тесты собирают сервис строкой: микросекунды, ноль flaky, транспорт не нужен.
- Пакет сервиса переносим в любой фреймворк — он не импортирует ни go-kit, ни net/http.
Домашнее задание
Развей свой сервис из прошлой домашки: добавь sentinel-ошибку для главного бизнес-отказа и протащи её через Err-поле response-структуры из урока 02. Затем выдели зависимость — хранилище, кэш или внешний клиент — в интерфейс, объявленный в пакете сервиса, и перепиши конструктор с проверкой на nil. Напиши table-driven тест не меньше чем на пять кейсов, включая проверку sentinel через errors.Is, и stub для зависимости с двумя режимами: честный и злобный, который возвращает ошибку. Финально заверни сервис в LoggingMiddleware из урока и убедись, что тесты прошли без единой правки: если пришлось что-то менять в реализации — декоратор написан неверно.
Личные заметкиПусто
Вопросы и ответы по уроку
Куда класть валидацию — в декодер или в сервис?
Формат — в декодер: парсинг дат, обязательные поля, длины. Бизнес — в сервис: уникальность email, права, лимиты. Правило: если проверка описывает, как читать запрос, — транспорт; если описывает, можно ли выполнить операцию, — сервис.
Чем service middleware отличается от endpoint middleware?
Уровнем видимости. Декоратор сервиса видит доменные аргументы и результат метода — ему подвластны бизнес-логи и метрики. Endpoint-обёртка видит request/response-структуры и error endpoint'а — ей подвластны логи запроса, лимиты, breaker. Часто нужна пара: лог на домене и лог на транспорте.
Можно ли тестировать сервис с моком Repository?
Да, и это штатный путь: интерфейс маленький, поэтому stub пишется в тестовом файле за десять строк — карта для записи и чтения плюс режим ошибки. Мок-библиотеки не обязательны: в Go чаще пишут простые заглушки руками, они читаются как спецификация контракта.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации