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:
| Status | What it means |
|---|---|
PENDING | created, waiting in the queue |
RUNNING | executing right now |
COMPLETED | done; resultUrl is mandatory |
FAILED | error; error is mandatory |
CANCELLED | cancelled 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:
detailin the error body — the human-readable explanationmessagein the list of validation violations
Always stay in English:
code— the machine-readable code (ORDER_NOT_FOUND), used in aswitchon the clienttitle— 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 OKeven on a partial error. - The response always contains
results(per element) andsummary(total/success/failed). - "All or nothing" atomicity is a rare case and requires an explicit declaration.
- A long-running task:
POST→202 Accepted+Location+statusUrl. The client pollsGET /tasks/{id}. - Five task statuses:
PENDING→RUNNING→COMPLETED/FAILED/CANCELLED. - On
COMPLETEDresultUrlis mandatory, onFAILED—error. - Localization is read from
Accept-Languageexplicitly, without middleware. The default language isru. - Only human-readable parts are localized:
detailandmessagein violations.code,title,typeand field names — always in English.
What to read next
- RFC 9457 errors in Go — the full error format:
ProblemDetails,violations,code,type. - Headers in Go —
Idempotency-Key,Location,traceparentand others. - JSON and response format in Go — response structure, pagination,
omitempty.