← Back to the section

Three situations that don't fit into standard CRUD: you need to process a hundred objects at once, a task takes several minutes, or a user expects an error message in their own language. In Go each of these is solved with explicit code — no framework magic, everything is visible in a single file.

Batch operations: when you need to process many objects at once

Suppose a client wants to create 50 orders in a single request. You could make 50 separate requests, but that's slow and loads the network. You need a single endpoint that accepts a list and returns a result for each element.

An important principle: partial success. If three out of 50 orders fail — that's not an error of the whole request. The server returns 200 OK and a separate result for each element: success or the reason for rejection. The client decides for itself what to do with those three.

Request and response structures

In Go it's convenient to use generic types so you don't write the same thing for every domain:

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 is intentionally simplified: only code and detail. The full error format is needed for the HTTP response as a whole — for an individual element in the list the minimum is enough.

Routing and the endpoint

A batch operation is always a POST, the endpoint is built as /resources/batch or /resources/batch/<action>:

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)
    })
})

Example handler

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)
}

The loop for _, item := range req.Items processes each element independently. An error on one doesn't interrupt processing of the rest.

What the response looks like

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
  }
}

The status is 200, even though one element failed. summary lets the client quickly grasp the picture without parsing the whole list.

Limiting the list size

Without an upper bound a client can send thousands of elements and bring down the server. The limit is set by a constant, a violation is a specific error code:

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
}

When atomicity is needed

Sometimes you need "all or nothing": if even one element fails — cancel everything. This is a rare case and must be explicitly stated in the endpoint's documentation. In that case any error returns 400, not 200. By default — always partial success.

Long-running tasks: launch and poll the status

Some operations can't be completed within a single HTTP request: generating a large report, exporting data, video processing. If the client waits — the connection will drop on timeout.

The solution is the polling pattern: the server immediately responds "task accepted", and the client periodically asks "how's it going?"

Launching a task — 202 Accepted

202 Accepted means "the request has been accepted, work is still ongoing". The response contains a task identifier and a link for polling:

type TaskAccepted struct {
    TaskID    string `json:"taskId"`
    Status    string `json:"status"`    // always "PENDING" on creation
    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)
}

The Location header is mandatory — it's exactly what standard HTTP clients use for redirection. statusUrl in the response body duplicates it for clients that don't read headers.

Polling the status — 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 on ResultURL, CompletedAt and Error — these fields aren't needed while the task is still running. They appear in the response only when they become relevant.

The task lifecycle

A task goes through five statuses:

StatusWhat it means
PENDINGcreated, waiting in the queue
RUNNINGexecuting right now
COMPLETEDdone; resultUrl is mandatory
FAILEDerror; error is mandatory
CANCELLEDcancelled by the client or the system

Example responses at different stages:

// While running
{
  "taskId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "RUNNING",
  "createdAt": "2026-06-19T10:30:00Z",
  "updatedAt": "2026-06-19T10:30:05Z"
}

// Done
{
  "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"
}

// Error
{
  "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": "Данные клиента за указанный период отсутствуют"
  }
}

The server may add a Retry-After header with a recommended interval in seconds:

w.Header().Set("Retry-After", "5")

A typical practice: 1–5 seconds for short tasks, 30–60 for long ones.

Localizing error messages

A user sees an error and wants to understand it in their own language. The client passes the preferred language via the Accept-Language header, and the server takes it into account when forming messages.

Reading the language from the request

In Go there's no magic — the header is read explicitly:

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"
}

The default language is ru. The acceptLanguage function is called explicitly in every handler that needs localization. No global state or middleware that silently changes behavior on you.

What is localized and what isn't

Only those parts of the response intended to be read by a human are localized:

  • detail in the error body — the human-readable explanation
  • message in the list of validation violations

Always stay in English:

  • code — the machine-readable code (ORDER_NOT_FOUND), used in a switch on the client
  • title — the standard HTTP status name (Bad Request, Not Found)
  • type — the URN identifier (urn:problem:order-service:order-not-found)
  • JSON field names — orderId, productId, always camelCase in Latin script

The logic is simple: if something is read by a program — it doesn't depend on the user's language.

The localization function

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
}

In practice the map is replaced with localization files (embed.FS with JSON per language) — the principle stays the same.

Example: the same request in two languages

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 and title are identical in both responses. Only detail changes.

Localizing validation errors

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
}

Batch operation together with localization

When you need to combine them — the language is read once at the start of the handler and passed to localizeMessage as a parameter:

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)
}

In short

  • A batch operation is always POST /resources/batch. The maximum size is fixed by a constant, exceeding it — 400 BATCH_SIZE_EXCEEDED.
  • By default — partial success: each element is processed independently, the response is 200 OK even on a partial error.
  • The response always contains results (per element) and summary (total/success/failed).
  • "All or nothing" atomicity is a rare case and requires an explicit declaration.
  • A long-running task: POST202 Accepted + Location + statusUrl. The client polls GET /tasks/{id}.
  • Five task statuses: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED.
  • On COMPLETED resultUrl is mandatory, on FAILEDerror.
  • Localization is read from Accept-Language explicitly, without middleware. The default language is ru.
  • Only human-readable parts are localized: detail and message in violations. code, title, type and field names — always in English.
  • RFC 9457 errors in Go — the full error format: ProblemDetails, violations, code, type.
  • Headers in Go — Idempotency-Key, Location, traceparent and others.
  • JSON and response format in Go — response structure, pagination, omitempty.