Три ситуации, которые не укладываются в стандартный CRUD: нужно обработать сразу сотню объектов, задача занимает несколько минут, или пользователь ожидает сообщение об ошибке на своём языке. В Go каждая из этих задач решается явным кодом — никакой магии фреймворка, всё видно в одном файле.
Групповые операции: когда нужно обработать много объектов сразу
Допустим, клиент хочет создать сразу 50 заказов одним запросом. Можно делать 50 отдельных запросов, но это медленно и нагружает сеть. Нужен один эндпоинт, который принимает список и возвращает результат по каждому элементу.
Важный принцип: частичный успех. Если из 50 заказов три не прошли — это не ошибка всего запроса. Сервер возвращает 200 OK и отдельный результат на каждый элемент: успех или причину отказа. Клиент сам решает, что делать с теми тремя.
Структуры запроса и ответа
В Go удобно использовать обобщённые типы (дженерики), чтобы не писать одно и то же для каждого домена:
type BatchRequest[T any] struct {
Items []T `json:"items" validate:"required,min=1,max=100"`
}
type BatchResult[T any] struct {
ID string `json:"id"`
Success bool `json:"success"`
Data *T `json:"data,omitempty"`
Error *ItemError `json:"error,omitempty"`
}
type ItemError struct {
Code string `json:"code"`
Detail string `json:"detail"`
}
type BatchResponse[T any] struct {
Results []BatchResult[T] `json:"results"`
Summary BatchSummary `json:"summary"`
}
type BatchSummary struct {
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
}
ItemError намеренно упрощён: только code и detail. Полный формат ошибки нужен для HTTP-ответа в целом — для конкретного элемента в списке достаточно минимума.
Роутинг и эндпоинт
Групповая операция — это всегда POST, эндпоинт строится как /resources/batch или /resources/batch/<действие>:
r.Route("/api/v1", func(r chi.Router) {
r.Route("/orders", func(r chi.Router) {
r.Post("/batch", batchCreateOrders)
r.Post("/batch/confirm", batchConfirmOrders)
})
r.Route("/customers", func(r chi.Router) {
r.Post("/batch", batchUpdateCustomers)
})
})
Пример обработчика
type CreateOrderItem struct {
ProductID string `json:"productId" validate:"required"`
Quantity int `json:"quantity" validate:"required,min=1"`
}
func batchCreateOrders(w http.ResponseWriter, r *http.Request) {
var req BatchRequest[CreateOrderItem]
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
if err := validate.Struct(req); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
if isMaxExceeded(ve) {
writeProblem(w, http.StatusBadRequest, "BATCH_SIZE_EXCEEDED",
"Bad Request", "Размер списка превышает максимум (100 элементов)",
traceIDFromCtx(r.Context()))
return
}
writeValidationProblem(w, toViolations(ve), traceIDFromCtx(r.Context()))
return
}
httperr.Write(w, r, err) // не ошибка полей, а сломанный вызов валидатора
return
}
lang := acceptLanguage(r)
resp := BatchResponse[OrderResponse]{
Results: make([]BatchResult[OrderResponse], 0, len(req.Items)),
Summary: BatchSummary{Total: len(req.Items)},
}
for _, item := range req.Items {
order, err := orderService.Create(r.Context(), item.ProductID, item.Quantity)
if err != nil {
resp.Results = append(resp.Results, BatchResult[OrderResponse]{
ID: item.ProductID,
Success: false,
Error: &ItemError{
Code: apperr.CodeOf(err),
Detail: localizeError(err, lang),
},
})
resp.Summary.Failed++
continue
}
out := toOrderResponse(order)
resp.Results = append(resp.Results, BatchResult[OrderResponse]{
ID: order.ID,
Success: true,
Data: &out,
})
resp.Summary.Success++
}
writeJSON(w, http.StatusOK, resp)
}
Цикл for _, item := range req.Items обрабатывает каждый элемент независимо. Ошибка на одном не прерывает обработку остальных. Это верно для доменных отказов — нет товара, неверная цена. Если же упала инфраструктура (база недоступна), продолжать цикл бессмысленно: такую ошибку отдают на весь запрос как 500, иначе клиент получит сто одинаковых «не удалось» и не поймёт, что дело не в данных.
Как выглядит ответ
HTTP/1.1 200 OK
Content-Type: application/json
{
"results": [
{
"id": "ord-001",
"success": true,
"data": { "orderId": "ord-001", "status": "NEW", "createdAt": "2026-06-19T08:00:00Z" }
},
{
"id": "prod-bbb",
"success": false,
"error": { "code": "INSUFFICIENT_STOCK", "detail": "Товар prod-bbb отсутствует на складе" }
},
{
"id": "ord-003",
"success": true,
"data": { "orderId": "ord-003", "status": "NEW", "createdAt": "2026-06-19T08:00:01Z" }
}
],
"summary": {
"total": 3,
"success": 2,
"failed": 1
}
}
Статус 200, несмотря на то что один элемент не прошёл. summary позволяет клиенту быстро понять картину без разбора всего списка. Альтернатива — 207 Multi-Status, но его понимают не все клиенты и прокси, поэтому здесь 200 и summary.
Ограничение размера списка
Без верхней границы клиент может прислать тысячи элементов и положить сервер. Ограничение задаётся константой, нарушение — конкретный код ошибки:
const maxBatchSize = 100
func checkBatchSize(size int, w http.ResponseWriter, r *http.Request) bool {
if size > maxBatchSize {
writeProblem(w, http.StatusBadRequest, "BATCH_SIZE_EXCEEDED",
"Bad Request",
fmt.Sprintf("Размер списка превышает максимум (%d элементов)", maxBatchSize),
traceIDFromCtx(r.Context()))
return false
}
return true
}
Когда нужна атомарность
Иногда требуется «всё или ничего»: если хоть один элемент не прошёл — отменить всё. Это редкий случай, его нужно явно обозначить в документации эндпоинта. Тогда при любой ошибке возвращается 400, а не 200. По умолчанию — всегда частичный успех.
Долгие задачи: запустить и опросить статус
Некоторые операции нельзя выполнить за время одного HTTP-запроса: генерация большого отчёта, экспорт данных, видеообработка. Если клиент будет ждать — соединение оборвётся по таймауту.
Решение — паттерн polling: сервер сразу отвечает «принял задачу», а клиент периодически спрашивает «как дела?»
Запуск задачи — 202 Accepted
202 Accepted означает «запрос принят, работа ещё идёт». В ответе — идентификатор задачи и ссылка для опроса:
type TaskAccepted struct {
TaskID string `json:"taskId"`
Status string `json:"status"` // всегда "PENDING" при создании
CreatedAt string `json:"createdAt"`
StatusURL string `json:"statusUrl"`
}
func exportCustomerData(w http.ResponseWriter, r *http.Request) {
customerID := chi.URLParam(r, "id")
var req struct {
Format string `json:"format" validate:"required,oneof=CSV JSON"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
taskID, err := exportService.Submit(r.Context(), customerID, req.Format)
if err != nil {
httperr.Write(w, r, err)
return
}
statusURL := "/api/v1/tasks/" + taskID
resp := TaskAccepted{
TaskID: taskID,
Status: "PENDING",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
StatusURL: statusURL,
}
w.Header().Set("Location", statusURL)
writeJSON(w, http.StatusAccepted, resp)
}
Заголовок Location по соглашению указывает на ресурс статуса задачи; автоматически клиенты по нему не переходят — 202 не редирект, — поэтому statusUrl в теле дублирует адрес для тех, кто заголовки не читает.
Опрос статуса — GET /tasks/{id}
type TaskStatus struct {
TaskID string `json:"taskId"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CompletedAt string `json:"completedAt,omitempty"`
ResultURL string `json:"resultUrl,omitempty"`
Error *ItemError `json:"error,omitempty"`
}
func getTaskStatus(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "id")
task, err := taskRepo.FindByID(r.Context(), taskID)
if err != nil {
httperr.Write(w, r, err)
return
}
resp := TaskStatus{
TaskID: task.ID,
Status: task.Status,
CreatedAt: task.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: task.UpdatedAt.UTC().Format(time.RFC3339),
}
switch task.Status {
case "COMPLETED":
resp.CompletedAt = task.CompletedAt.UTC().Format(time.RFC3339)
resp.ResultURL = "/api/v1/exports/" + task.ResultID
case "FAILED":
resp.CompletedAt = task.CompletedAt.UTC().Format(time.RFC3339)
resp.Error = &ItemError{
Code: task.ErrorCode,
Detail: task.ErrorDetail,
}
case "CANCELLED":
resp.CompletedAt = task.CompletedAt.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, resp)
}
omitempty на ResultURL, CompletedAt и Error — эти поля не нужны, пока задача ещё работает. Они появятся в ответе только когда станут актуальными.
Жизненный цикл задачи
Задача проходит через пять статусов:
| Статус | Что значит |
|---|---|
PENDING | создана, ожидает очереди |
RUNNING | выполняется прямо сейчас |
COMPLETED | готово; resultUrl обязателен |
FAILED | ошибка; error обязателен |
CANCELLED | отменена клиентом или системой |
Раз статус CANCELLED существует, должен быть и способ в него попасть: POST /tasks/{id}/cancel переводит PENDING или RUNNING в CANCELLED и отвечает 409, если задача уже завершилась.
Пример ответов на разных стадиях:
// Пока работает
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "RUNNING",
"createdAt": "2026-06-19T10:30:00Z",
"updatedAt": "2026-06-19T10:30:05Z"
}
// Готово
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "COMPLETED",
"createdAt": "2026-06-19T10:30:00Z",
"updatedAt": "2026-06-19T10:35:00Z",
"completedAt": "2026-06-19T10:35:00Z",
"resultUrl": "/api/v1/exports/550e8400-e29b-41d4-a716-446655440000"
}
// Ошибка
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "FAILED",
"createdAt": "2026-06-19T10:30:00Z",
"updatedAt": "2026-06-19T10:31:00Z",
"completedAt": "2026-06-19T10:31:00Z",
"error": {
"code": "EXPORT_DATA_UNAVAILABLE",
"detail": "Данные клиента за указанный период отсутствуют"
}
}
Сервер может добавить заголовок Retry-After с рекомендованным интервалом в секундах:
w.Header().Set("Retry-After", "5")
Типичная практика: 1–5 секунд для коротких задач, 30–60 для длинных.
Локализация сообщений об ошибках
Пользователь видит ошибку и хочет понять её на своём языке. Клиент передаёт предпочтительный язык через заголовок Accept-Language, сервер учитывает его при формировании сообщений.
Читаем язык из запроса
В Go нет никакой магии — заголовок читается явно:
func acceptLanguage(r *http.Request) string {
lang := r.Header.Get("Accept-Language")
if lang == "" {
return "ru"
}
parts := strings.Split(lang, ",")
if len(parts) > 0 {
tag := strings.TrimSpace(strings.Split(parts[0], ";")[0])
if tag != "" {
return tag
}
}
return "ru"
}
Язык по умолчанию — ru. Функция acceptLanguage вызывается явно в каждом обработчике, которому нужна локализация. Никакого глобального состояния или middleware, который незаметно для вас меняет поведение.
Что локализуется, а что нет
Локализуются только те части ответа, которые предназначены для чтения человеком:
detailв теле ошибки — читаемое объяснениеmessageв списке нарушений валидации
Остаются на английском всегда:
code— машиночитаемый код (ORDER_NOT_FOUND), его используют вswitchна клиентеtitle— стандартное название HTTP-статуса (Bad Request,Not Found)type— URN-идентификатор (urn:problem:order-service:order-not-found)- имена JSON-полей —
orderId,productId, всегда camelCase латиницей
Логика простая: если что-то читает программа — оно не зависит от языка пользователя.
Функция локализации
func localizeMessage(code, lang string) string {
msgs := map[string]map[string]string{
"ORDER_NOT_FOUND": {
"ru": "Заказ не найден",
"en": "Order not found",
},
"PRODUCT_DISCONTINUED": {
"ru": "Товар снят с продажи",
"en": "Product is discontinued",
},
"INSUFFICIENT_STOCK": {
"ru": "Недостаточно товара на складе",
"en": "Insufficient stock",
},
}
if m, ok := msgs[code]; ok {
if msg, ok := m[lang]; ok {
return msg
}
if msg, ok := m["ru"]; ok {
return msg
}
}
return code
}
На практике map заменяют файлами локализации (embed.FS с JSON по языкам) — принцип остаётся тем же. Разбор Accept-Language с весами q= и подбор ближайшего из поддерживаемых языков лучше отдать golang.org/x/text/language: language.ParseAcceptLanguage плюс language.NewMatcher, иначе ru-RU,ru;q=0.9,en;q=0.8 из функции выше прочитается как ru-RU, которого в словаре нет.
Пример: один и тот же запрос на двух языках
живой пример
GET /api/v1/orders/ord-999
Accept-Language: en
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Общий вид ошибки для всех доменов один и тот же:
{
"type": "urn:problem:order-service:order-not-found",
"title": "Not Found",
"status": 404,
"detail": "Order not found",
"code": "ORDER_NOT_FOUND"
}
Тот же ответ с доменным кодом - клиенту не нужно разбирать type:
живой пример
GET /api/v1/orders/ord-999
Accept-Language: ru
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Общий вид ошибки для всех доменов один и тот же:
{
"type": "urn:problem:order-service:order-not-found",
"title": "Not Found",
"status": 404,
"detail": "Заказ не найден",
"code": "ORDER_NOT_FOUND"
}
code и title одинаковые в обоих ответах. Меняется только detail.
Локализация ошибок валидации
func writeLocalizedValidationProblem(w http.ResponseWriter, errs validator.ValidationErrors, lang, traceID string) {
violations := make([]Violation, 0, len(errs))
for _, e := range errs {
violations = append(violations, Violation{
Field: toFieldName(e.Namespace()),
Code: strings.ToUpper(e.Tag()),
Message: localizeValidationMessage(e.Tag(), lang),
})
}
writeValidationProblem(w, violations, traceID)
}
func localizeValidationMessage(tag, lang string) string {
msgs := map[string]map[string]string{
"required": {"ru": "Поле обязательно", "en": "Field is required"},
"min": {"ru": "Значение слишком мало", "en": "Value is too small"},
"max": {"ru": "Значение слишком велико", "en": "Value is too large"},
}
if m, ok := msgs[tag]; ok {
if msg, ok := m[lang]; ok {
return msg
}
}
return tag
}
Групповая операция с локализацией вместе
Когда нужно совместить — язык читается один раз в начале обработчика и передаётся в localizeMessage параметром:
func batchProcessPayments(w http.ResponseWriter, r *http.Request) {
lang := acceptLanguage(r)
var req BatchRequest[PaymentItem]
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
if !checkBatchSize(len(req.Items), w, r) {
return
}
resp := BatchResponse[PaymentResponse]{
Results: make([]BatchResult[PaymentResponse], 0, len(req.Items)),
Summary: BatchSummary{Total: len(req.Items)},
}
for _, item := range req.Items {
payment, err := paymentService.Process(r.Context(), item.AccountID, item.Amount, item.Currency)
if err != nil {
resp.Results = append(resp.Results, BatchResult[PaymentResponse]{
ID: item.AccountID,
Success: false,
Error: &ItemError{
Code: apperr.CodeOf(err),
Detail: localizeMessage(apperr.CodeOf(err), lang),
},
})
resp.Summary.Failed++
continue
}
out := toPaymentResponse(payment)
resp.Results = append(resp.Results, BatchResult[PaymentResponse]{
ID: payment.ID,
Success: true,
Data: &out,
})
resp.Summary.Success++
}
writeJSON(w, http.StatusOK, resp)
}
Коротко
- Групповая операция — всегда
POST /resources/batch. Максимальный размер фиксируется константой, превышение —400 BATCH_SIZE_EXCEEDED. - По умолчанию — частичный успех: каждый элемент обрабатывается независимо, ответ
200 OKдаже при частичной ошибке. - В ответе всегда есть
results(поэлементно) иsummary(total/success/failed). - Атомарность «всё или ничего» — редкий случай, требует явного объявления.
- Долгая задача:
POST→202 Accepted+Location+statusUrl. Клиент опрашиваетGET /tasks/{id}. - Пять статусов задачи:
PENDING→RUNNING→COMPLETED/FAILED/CANCELLED. - При
COMPLETEDобязателенresultUrl, приFAILED—error. - Локализация читается из
Accept-Languageявно, без middleware. Язык по умолчанию —ru. - Локализуются только человекочитаемые части:
detailиmessageв нарушениях.code,title,typeи имена полей — всегда на английском.
Что почитать дальше
- Ошибки RFC 9457 в Go — полный формат ошибки:
ProblemDetails,violations,code,type. - Заголовки в Go —
Idempotency-Key,Location,traceparentи другие. - JSON и формат ответов в Go — структура ответа, пагинация,
omitempty.