Три ситуации, которые не укладываются в стандартный 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
}
}
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
}
resp.Results = append(resp.Results, BatchResult[OrderResponse]{
ID: order.ID,
Success: true,
Data: toOrderResponse(order),
})
resp.Summary.Success++
}
writeJSON(w, http.StatusOK, resp)
}
Цикл for _, item := range req.Items обрабатывает каждый элемент независимо. Ошибка на одном не прерывает обработку остальных.
Как выглядит ответ
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 позволяет клиенту быстро понять картину без разбора всего списка.
Ограничение размера списка
Без верхней границы клиент может прислать тысячи элементов и положить сервер. Ограничение задаётся константой, нарушение — конкретный код ошибки:
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 обязателен — именно его стандартные HTTP-клиенты используют для переадресации. 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 | отменена клиентом или системой |
Пример ответов на разных стадиях:
// Пока работает
{
"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 по языкам) — принцип остаётся тем же.
Пример: один и тот же запрос на двух языках
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"
}
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
}
resp.Results = append(resp.Results, BatchResult[PaymentResponse]{
ID: payment.ID,
Success: true,
Data: toPaymentResponse(payment),
})
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.