1. Почему логика в handler’е со временем начинает мешать
Первые несколько эндпоинтов почти всегда пишутся одинаково: в handler’е мы парсим id, читаем JSON, валидируем поля, дергаем хранилище, решаем какой статус вернуть, формируем JSON и печатаем. Пока проект маленький — кажется, что так и надо. Но потом вы добавляете второй эндпоинт, третий, и внезапно обнаруживаете, что бизнес‑правила размазаны по HTTP‑коду, а тесты превращаются в “проверим, что в этой ветке было 400, а в той 404”.
Главная проблема не в том, что handler “слишком длинный”. Проблема в том, что HTTP‑слой начинает принимать решения, которые ему не принадлежат. Он должен уметь читать запрос и писать ответ, но не должен быть “главным мыслителем” системы. Как только бизнес‑правило меняется, вы правите пять handler’ов, шесть тестов httptest, и ещё два места, где “мы почти так же делали”. Это очень похоже на ситуацию, когда вы храните всю логику приложения в main() — однажды это просто перестаёт помещаться в голову.
Чтобы выбраться из этой ловушки, мы вводим дисциплину: core‑логика — отдельно, HTTP — отдельно. Тогда handler становится тонким переводчиком, а не автором романа.
Что такое core и HTTP‑слой
Когда говорят “разделение ответственности”, новичок часто слышит: “вам срочно нужно 18 папок и 47 интерфейсов”. Это неправда (и да, я тоже когда-то так думал). На практике нам нужен всего один простой принцип: core не должен знать, что его вызывают по HTTP. Он должен уметь выполнять действия в терминах предметной области: “создать задачу”, “получить список задач”, “пометить задачу выполненной”, “вернуть ошибку валидации, если вход плохой”.
HTTP‑слой, наоборот, должен быть хорош в том, в чём хорош HTTP: разобрать URL, метод, заголовки, JSON‑тело, выбрать статус‑код, положить Content-Type, вернуть error envelope. В Go это обычно означает, что HTTP‑слой импортирует net/http и encoding/json, а core‑слой — нет.
Удобно держать в голове такую табличку:
| Вопрос | Кто отвечает |
|---|---|
| “Что делать, если title пустой?” | Core |
| “Какой статус возвращаем при validation?” | HTTP‑слой (маппинг) |
| “Как распарсить {id} из пути?” | HTTP‑слой |
| “Что значит ‘задачи не существует’?” | Core (ошибка not found) |
| “Какая форма JSON у ошибки?” | HTTP‑слой (контракт API) |
Важно: это не означает, что core “вообще ничего не возвращает”. Он возвращает результаты и ошибки, но в своих терминах.
Кстати, то, что ServeMux теперь умеет patterns с методами и {id}, делает HTTP‑слой удобнее, но не делает его бизнес‑слоем. Он всё равно остаётся транспортом.
2. Контракт и реализация слоёв
Контракт: входы, выходы и доменные ошибки
Чтобы слои дружили, им нужен контракт. Причём желательно такой, чтобы не приходилось тащить *http.Request в core. Контракт обычно выглядит так: core получает “нормальные” типы (int, string, time.Time, структуры), а возвращает либо результат, либо ошибку. Ошибка может быть типизированной, чтобы HTTP‑слой мог понять класс: validation/not_found/internal.
В наших предыдущих темах мы уже привыкли к тому, что ошибки в Go — это значения, и по ним можно принимать решения через errors.Is/errors.As. Это полезно не только для красивых “обёрток” ошибок, но и для архитектуры: core возвращает осмысленную ошибку, а HTTP‑слой делает маппинг. В Go это стандартная практика: ошибка может хранить контекст, а сверху её можно оборачивать, не теряя смысл (через цепочку причин).
Для нашего учебного API задач заведём две доменные ошибки: ValidationError и NotFoundError. Они будут жить в core и ничего не знать про JSON и статус‑коды.
package core
type ValidationError struct {
Fields map[string]string
}
func (e ValidationError) Error() string {
return "validation error"
}
type NotFoundError struct {
ID int
}
func (e NotFoundError) Error() string {
return "not found"
}
Заметьте: тексты ошибок здесь “внутренние”. То, что увидит клиент в error.message, определит HTTP‑слой. Это как переводчик: мысль одна, язык разный.
Core‑слой: модель и интерфейс хранилища
Сейчас мы соберём минимальный “мозг” приложения задач. Пусть у нас есть задача: ID, Title, Done. Это чистая модель предметной области; ей не надо знать про JSON‑теги, потому что JSON — это транспортная форма.
package core
type Task struct {
ID int
Title string
Done bool
}
Дальше core обычно не ходит напрямую в базу данных (или в in-memory map). Он общается через интерфейс “хранилища”. Это важно не ради моды, а ради тестируемости: в тестах core мы сможем подставить фейковое хранилище без диска, сети и сложных приготовлений.
package core
import "context"
type TaskStore interface {
Create(ctx context.Context, title string) (Task, error)
GetByID(ctx context.Context, id int) (Task, error)
MarkDone(ctx context.Context, id int) (Task, error)
}
Обратите внимание: context.Context уже не является чисто HTTP‑штукой. Он давно используется как “канал отмены/таймаута” и для проброса request‑scoped информации. Поэтому прокидывать ctx в core — нормальная практика.
Usecase‑методы: бизнес‑логика без net/http
Теперь сделаем “сервис” (usecase‑слой). Это объект, который реализует правила: валидирует вход, вызывает хранилище, интерпретирует ошибки хранилища (если нужно) и возвращает доменные ошибки.
package core
import (
"context"
"strings"
)
type TaskService struct {
store TaskStore
}
func NewTaskService(store TaskStore) TaskService {
return TaskService{store: store}
}
func (s TaskService) Create(ctx context.Context, title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, ValidationError{Fields: map[string]string{"title": "must not be empty"}}
}
return s.store.Create(ctx, title)
}
Заметьте, насколько “чистым” получился метод Create: никаких *http.Request, никаких json.Decoder, никаких статус‑кодов. Это просто функция “создать задачу”.
Теперь сделаем “пометить выполненной”. Тут появляется важный момент: если id плохой (например <= 0) — это validation. Если задачи не существует — not found. Это бизнес‑смысл, а не HTTP‑смысл.
package core
import "context"
func (s TaskService) MarkDone(ctx context.Context, id int) (Task, error) {
if id <= 0 {
return Task{}, ValidationError{Fields: map[string]string{"id": "must be positive"}}
}
t, err := s.store.MarkDone(ctx, id)
if err != nil {
return Task{}, err
}
return t, nil
}
Пока мы просто прокидываем ошибку хранилища наружу. В реальном проекте вы часто делаете так: хранилище возвращает NotFoundError, и сервис его не меняет. Главное, что внутренний смысл ошибки сохраняется, а HTTP‑слой решает, как это показать клиенту.
HTTP‑слой: адаптер decode → core → encode
Теперь, когда core умеет “думать”, HTTP‑слой может спокойно стать переводчиком. Он не обязан быть умным, ему достаточно быть аккуратным.
Сделаем DTO для запроса создания задачи. DTO — это именно HTTP‑форма данных, поэтому JSON‑теги здесь уместны.
package httpapi
type createTaskRequest struct {
Title string `json:"title"`
}
Теперь handler: он читает JSON, достаёт Title, зовёт svc.Create(...), а затем делает маппинг ошибок в наш error envelope.
package httpapi
import (
"encoding/json"
"net/http"
"example.com/app/core"
)
type TaskHandler struct {
svc core.TaskService
}
func (h TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
var req createTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "validation", "invalid request", nil)
return
}
task, err := h.svc.Create(r.Context(), req.Title)
if err != nil {
h.writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, task)
}
Обратите внимание на две вещи.
Первая: handler не делает strings.TrimSpace и не решает “пустой title — это плохо”. Он отдаёт это core.
Вторая: в handler не расползается логика маппинга ошибок — мы вынесли её в h.writeErr. Это делает HTTP‑слой единообразным и уменьшает копипасту.
Пример writeErr, который узнаёт доменные ошибки через errors.As и возвращает один и тот же формат ответа:
package httpapi
import (
"errors"
"net/http"
"example.com/app/core"
)
func (h TaskHandler) writeErr(w http.ResponseWriter, err error) {
var vErr core.ValidationError
if errors.As(err, &vErr) {
writeError(w, http.StatusBadRequest, "validation", "invalid request", vErr.Fields)
return
}
var nf core.NotFoundError
if errors.As(err, &nf) {
writeError(w, http.StatusNotFound, "not_found", "resource not found", nil)
return
}
writeError(w, http.StatusInternalServerError, "internal", "internal error", nil)
}
Здесь мы соблюдаем важное правило контракта: для 500‑класса не отдаём клиенту err.Error(). Клиенту достаточно “internal error”, а подробности уходят в логи (логирование мы уже обсуждали в других частях курса, но в HTTP‑контракте это правило фиксируется особенно строго).
Наконец, наши вспомогательные функции ответа. Они полностью HTTP‑специфичны, поэтому живут в HTTP‑пакете.
package httpapi
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
И error envelope:
package httpapi
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type errorEnvelope struct {
Error apiError `json:"error"`
}
package httpapi
import (
"encoding/json"
"net/http"
)
func writeError(w http.ResponseWriter, status int, code, msg string, fields map[string]string) {
writeJSON(w, status, errorEnvelope{
Error: apiError{Code: code, Message: msg, Fields: fields},
})
}
Да, writeError зависит от writeJSON. И да, это нормально: внутри HTTP‑слоя мы строим “единый путь ответа”, чтобы тестировать контракт стабильно.
4. Тестирование: core без httptest, HTTP — по контракту
Когда core вынесен из HTTP, появляется очень приятный бонус: тесты бизнеса больше не требуют симуляции запросов, URL, ResponseRecorder и JSON‑декодинга. Мы просто вызываем методы сервиса как обычные функции. Тест становится короче, быстрее и понятнее.
Например, протестируем, что пустой title даёт ValidationError с полем title. Для теста нам даже не нужно настоящее хранилище, потому что валидация срабатывает раньше. Можно подсунуть nil‑store, но аккуратнее сделать заглушку.
package core_test
import (
"context"
"testing"
"example.com/app/core"
)
type nopStore struct{}
func (nopStore) Create(ctx context.Context, title string) (core.Task, error) {
return core.Task{ID: 1, Title: title}, nil
}
func (nopStore) GetByID(ctx context.Context, id int) (core.Task, error) { return core.Task{}, nil }
func (nopStore) MarkDone(ctx context.Context, id int) (core.Task, error) { return core.Task{}, nil }
func TestTaskService_Create_EmptyTitle(t *testing.T) {
svc := core.NewTaskService(nopStore{})
_, err := svc.Create(context.Background(), " ")
if err == nil {
t.Fatalf("err=nil, want validation error")
}
}
Пока мы проверили только факт ошибки. Давайте проверим структуру Fields через errors.As — и снова без HTTP.
package core_test
import (
"context"
"errors"
"testing"
"example.com/app/core"
)
func TestTaskService_Create_ValidationFields(t *testing.T) {
svc := core.NewTaskService(nopStore{})
_, err := svc.Create(context.Background(), "")
var vErr core.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("err=%T, want ValidationError", err)
}
if vErr.Fields["title"] != "must not be empty" {
t.Fatalf("fields.title=%q", vErr.Fields["title"])
}
}
Вот здесь обычно приходит ощущение: “Ого, я протестировал бизнес‑правило, и мне не понадобился HTTP вообще”. Да, именно так. Это и есть цель разделения ответственности.
А HTTP‑слой в такой архитектуре тестируется проще: мы уже знаем, что core корректно классифицирует ошибки, и теперь HTTP‑тесты могут фокусироваться на маппинге “ошибка → статус/envelope”, а не на валидации каждого правила в каждом handler’е.
5. Итоговая схема: поток данных и две границы
Полезно один раз увидеть общую схему, чтобы мозг перестал пытаться “впихнуть всё в handler”. Представьте запрос как путешественника, который проходит таможню два раза: на входе (HTTP decode) и на выходе (HTTP encode). Всё, что между ними — core.
flowchart TD
A[HTTP Request] --> B[HTTP decode: JSON, path params]
B --> C[core: validate + usecase]
C --> D[core result or domain error]
D --> E[HTTP map: error -> status + envelope]
E --> F[HTTP Response JSON]
Смысл этой картинки не в том, чтобы вы запомнили стрелочки. Смысл в том, что валидация доменных правил живёт в core, а формат ответа живёт в HTTP. Тогда изменения не размазываются: поменяли правило “title должен быть минимум 3 символа” — правим core и его тесты. Поменяли контракт API (например, сообщение для not_found) — правим HTTP и его контрактные тесты. Эти изменения не мешают друг другу, и это очень успокаивает.
6. Типичные ошибки при разделении core и HTTP
Ошибка №1: протащить *http.Request в core “просто чтобы не передавать параметры”.
Так делать соблазнительно: “а что, в реквесте же всё есть”. Но это превращает core в зависимого от транспорта. Как только вы захотите вызвать ту же логику из CLI или из другого адаптера, вы поймёте, что таскаете HTTP внутрь мозга приложения. Лучше передать в core только то, что ему нужно: id, title, ctx.
Ошибка №2: дублировать валидацию и в handler’е, и в core.
Иногда делают так: handler проверяет title != "", а core тоже проверяет. Кажется, что это “безопаснее”, но на деле вы получаете два источника правды. Через месяц проверки разъезжаются, и баг появляется не потому что вы “плохо написали код”, а потому что у вас две версии реальности. В HTTP‑слое оставляйте только транспортные проверки (например, “JSON вообще читается”), а бизнес‑валидацию держите в core.
Ошибка №3: возвращать из core уже готовые HTTP‑статусы или JSON‑структуры.
Core не должен говорить “верни 404”. Core должен сказать “не найдено”. HTTP‑слой решит, что “не найдено” в HTTP‑контракте — это 404 + {error:{code:"not_found",...}}. Если вы смешаете уровни, вы зацементируете транспорт в бизнес‑логике.
Ошибка №4: в 500‑ответах отдавать наружу err.Error() ради “удобства дебага”.
Это ломает контракт и безопасность: внутренние детали (пути, SQL‑запросы, неожиданные сообщения) утекают наружу. Правильный подход — стабильное сообщение клиенту и подробности в логах. Разделение ответственности помогает это дисциплинировать: core может вернуть “настоящую” ошибку, но HTTP‑слой обязан скрыть её текст для 500.
Ошибка №5: тестировать бизнес‑правила только через HTTP‑тесты.
Можно, конечно, проверить всё через httptest.NewServer, но тесты будут длиннее, медленнее и хрупче. Когда core отделён, бизнес‑правила тестируются обычными unit‑тестами без HTTP, а HTTP‑тесты становятся короткой проверкой контракта: статус, заголовки и структура error envelope. Это в итоге даёт меньше кода и больше уверенности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ