JavaRush /Курсы /Go SELF /Разделение ответственности — core‑логика отдельно от HTTP...

Разделение ответственности — core‑логика отдельно от HTTP слоя

Go SELF
64 уровень , 3 лекция
Открыта

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. Это в итоге даёт меньше кода и больше уверенности.

1
Задача
Go SELF, 64 уровень, 3 лекция
Недоступна
Валидация ядра
Валидация ядра
1
Задача
Go SELF, 64 уровень, 3 лекция
Недоступна
Маппинг ошибок
Маппинг ошибок
1
Задача
Go SELF, 64 уровень, 3 лекция
Недоступна
Сервис задач
Сервис задач
1
Задача
Go SELF, 64 уровень, 3 лекция
Недоступна
HTTP адаптер
HTTP адаптер
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ