HTTP y APIs en Go: servicios web rápidos y mantenibles

Capítulo 8

Tiempo estimado de lectura: 13 minutos

+ Ejercicio

API REST con net/http: visión general

En Go, una API HTTP suele componerse de: (1) un router que decide qué handler se ejecuta según método y ruta, (2) handlers que traducen HTTP/JSON a llamadas de negocio, (3) servicios con reglas de negocio, (4) repositorios para persistencia, y (5) middlewares para preocupaciones transversales (logging, timeouts, auth, etc.). El objetivo es mantener el transporte (HTTP) desacoplado del dominio para que el código sea testeable y mantenible.

Contrato JSON: recursos, envoltorios y errores consistentes

Diseño del recurso

Usaremos un recurso Task (tarea) con CRUD. El contrato JSON debe ser estable: nombres consistentes, tipos claros y fechas en formato estándar (p. ej. RFC3339). Para simplificar, usaremos id, title, done y created_at.

Formato de error uniforme

Un error consistente facilita clientes y observabilidad. Un patrón común es responder siempre con un objeto error con campos predecibles.

{"error":{"code":"validation_error","message":"title is required","details":{"field":"title"}}}

Recomendación: mapear errores de dominio a códigos HTTP y a un code estable para el cliente.

Estructura por capas (transporte, servicio, repositorio)

Una estructura mínima orientada a capas puede verse así:

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

internal/  transport/http/    server.go    middleware.go    handlers_tasks.go  service/    tasks.go  repository/    tasks_memory.go  domain/    task.go    errors.go
  • domain: entidades y errores de dominio (sin HTTP).
  • repository: interfaz y una implementación (en memoria para el ejemplo).
  • service: reglas de negocio y validación.
  • transport/http: handlers, router, middlewares, codificación/decodificación JSON.

Rutas y handlers con net/http (sin framework)

net/http no trae un router avanzado, pero desde Go 1.22 el ServeMux soporta patrones con variables. Usaremos:

  • GET /tasks (lista con paginación)
  • POST /tasks (crear)
  • GET /tasks/{id} (obtener)
  • PUT /tasks/{id} (reemplazar)
  • DELETE /tasks/{id} (borrar)

Ejemplo completo: API CRUD con paginación, validación, middlewares, métricas y pruebas

Dominio: entidad y errores

package domainimport "errors"var (  ErrNotFound = errors.New("not found")  ErrConflict = errors.New("conflict")  ErrValidation = errors.New("validation"))type Task struct {  ID        string `json:"id"`  Title     string `json:"title"`  Done      bool   `json:"done"`  CreatedAt string `json:"created_at"`}

Repositorio: interfaz e implementación en memoria

package repositoryimport (  "context"  "sort"  "sync"  "time"  "github.com/google/uuid"  "yourmodule/internal/domain")type TaskRepository interface {  Create(ctx context.Context, title string) (domain.Task, error)  Get(ctx context.Context, id string) (domain.Task, error)  Update(ctx context.Context, id string, title string, done bool) (domain.Task, error)  Delete(ctx context.Context, id string) error  List(ctx context.Context, offset, limit int) ([]domain.Task, int, error)}type memoryTaskRepo struct {  mu    sync.RWMutex  items map[string]domain.Task  order []string}func NewMemoryTaskRepo() TaskRepository {  return &memoryTaskRepo{items: map[string]domain.Task{}}}func (r *memoryTaskRepo) Create(ctx context.Context, title string) (domain.Task, error) {  r.mu.Lock()  defer r.mu.Unlock()  id := uuid.NewString()  t := domain.Task{ID: id, Title: title, Done: false, CreatedAt: time.Now().UTC().Format(time.RFC3339)}  r.items[id] = t  r.order = append(r.order, id)  return t, nil}func (r *memoryTaskRepo) Get(ctx context.Context, id string) (domain.Task, error) {  r.mu.RLock()  defer r.mu.RUnlock()  t, ok := r.items[id]  if !ok {    return domain.Task{}, domain.ErrNotFound  }  return t, nil}func (r *memoryTaskRepo) Update(ctx context.Context, id string, title string, done bool) (domain.Task, error) {  r.mu.Lock()  defer r.mu.Unlock()  t, ok := r.items[id]  if !ok {    return domain.Task{}, domain.ErrNotFound  }  t.Title = title  t.Done = done  r.items[id] = t  return t, nil}func (r *memoryTaskRepo) Delete(ctx context.Context, id string) error {  r.mu.Lock()  defer r.mu.Unlock()  if _, ok := r.items[id]; !ok {    return domain.ErrNotFound  }  delete(r.items, id)  for i := range r.order {    if r.order[i] == id {      r.order = append(r.order[:i], r.order[i+1:]...)      break    }  }  return nil}func (r *memoryTaskRepo) List(ctx context.Context, offset, limit int) ([]domain.Task, int, error) {  r.mu.RLock()  defer r.mu.RUnlock()  total := len(r.order)  ids := append([]string(nil), r.order...)  sort.Strings(ids)  if offset < 0 { offset = 0 }  if limit <= 0 { limit = 20 }  if offset > total {    return []domain.Task{}, total, nil  }  end := offset + limit  if end > total { end = total }  out := make([]domain.Task, 0, end-offset)  for _, id := range ids[offset:end] {    out = append(out, r.items[id])  }  return out, total, nil}

Servicio: validación, reglas y mapeo de errores

package serviceimport (  "context"  "strings"  "yourmodule/internal/domain"  "yourmodule/internal/repository")type TaskService struct {  repo repository.TaskRepository}func NewTaskService(r repository.TaskRepository) *TaskService {  return &TaskService{repo: r}}func (s *TaskService) Create(ctx context.Context, title string) (domain.Task, error) {  title = strings.TrimSpace(title)  if title == "" {    return domain.Task{}, domain.ErrValidation  }  return s.repo.Create(ctx, title)}func (s *TaskService) Get(ctx context.Context, id string) (domain.Task, error) {  if strings.TrimSpace(id) == "" {    return domain.Task{}, domain.ErrValidation  }  return s.repo.Get(ctx, id)}func (s *TaskService) Update(ctx context.Context, id, title string, done bool) (domain.Task, error) {  id = strings.TrimSpace(id)  title = strings.TrimSpace(title)  if id == "" || title == "" {    return domain.Task{}, domain.ErrValidation  }  return s.repo.Update(ctx, id, title, done)}func (s *TaskService) Delete(ctx context.Context, id string) error {  if strings.TrimSpace(id) == "" {    return domain.ErrValidation  }  return s.repo.Delete(ctx, id)}func (s *TaskService) List(ctx context.Context, offset, limit int) ([]domain.Task, int, error) {  if offset < 0 || limit < 0 {    return nil, 0, domain.ErrValidation  }  return s.repo.List(ctx, offset, limit)}

Transporte HTTP: utilidades JSON, errores y códigos de estado

Centraliza la escritura de respuestas para evitar duplicación y asegurar consistencia.

package httptransportimport (  "encoding/json"  "net/http"  "yourmodule/internal/domain")type apiError struct {  Code    string      `json:"code"`  Message string      `json:"message"`  Details interface{} `json:"details,omitempty"`}type errorEnvelope struct {  Error apiError `json:"error"`}func writeJSON(w http.ResponseWriter, status int, v any) {  w.Header().Set("Content-Type", "application/json")  w.WriteHeader(status)  _ = json.NewEncoder(w).Encode(v)}func writeError(w http.ResponseWriter, status int, code, msg string, details any) {  writeJSON(w, status, errorEnvelope{Error: apiError{Code: code, Message: msg, Details: details}})}func mapDomainError(w http.ResponseWriter, err error) {  switch err {  case nil:    return  case domain.ErrNotFound:    writeError(w, http.StatusNotFound, "not_found", "resource not found", nil)  case domain.ErrValidation:    writeError(w, http.StatusBadRequest, "validation_error", "invalid input", nil)  case domain.ErrConflict:    writeError(w, http.StatusConflict, "conflict", "conflict", nil)  default:    writeError(w, http.StatusInternalServerError, "internal", "internal server error", nil)  }}

Handlers: rutas, decodificación, paginación y status codes

Buenas prácticas: limitar tamaño del body, rechazar JSON inválido, devolver 201 al crear, 204 al borrar, y 400/404/409 según corresponda.

package httptransportimport (  "encoding/json"  "net/http"  "strconv"  "yourmodule/internal/service")type TaskHandlers struct {  svc *service.TaskService}func NewTaskHandlers(s *service.TaskService) *TaskHandlers {  return &TaskHandlers{svc: s}}type createTaskRequest struct {  Title string `json:"title"`}type updateTaskRequest struct {  Title string `json:"title"`  Done  bool   `json:"done"`}type listResponse struct {  Items any `json:"items"`  Page  struct {    Offset int `json:"offset"`    Limit  int `json:"limit"`    Total  int `json:"total"`  } `json:"page"`}func (h *TaskHandlers) Create(w http.ResponseWriter, r *http.Request) {  r.Body = http.MaxBytesReader(w, r.Body, 1<<20)  var req createTaskRequest  if err := json.NewDecoder(r.Body).Decode(&req); err != nil {    writeError(w, http.StatusBadRequest, "invalid_json", "invalid json", nil)    return  }  t, err := h.svc.Create(r.Context(), req.Title)  if err != nil {    mapDomainError(w, err)    return  }  writeJSON(w, http.StatusCreated, t)}func (h *TaskHandlers) Get(w http.ResponseWriter, r *http.Request) {  id := r.PathValue("id")  t, err := h.svc.Get(r.Context(), id)  if err != nil {    mapDomainError(w, err)    return  }  writeJSON(w, http.StatusOK, t)}func (h *TaskHandlers) Update(w http.ResponseWriter, r *http.Request) {  id := r.PathValue("id")  r.Body = http.MaxBytesReader(w, r.Body, 1<<20)  var req updateTaskRequest  if err := json.NewDecoder(r.Body).Decode(&req); err != nil {    writeError(w, http.StatusBadRequest, "invalid_json", "invalid json", nil)    return  }  t, err := h.svc.Update(r.Context(), id, req.Title, req.Done)  if err != nil {    mapDomainError(w, err)    return  }  writeJSON(w, http.StatusOK, t)}func (h *TaskHandlers) Delete(w http.ResponseWriter, r *http.Request) {  id := r.PathValue("id")  if err := h.svc.Delete(r.Context(), id); err != nil {    mapDomainError(w, err)    return  }  w.WriteHeader(http.StatusNoContent)}func (h *TaskHandlers) List(w http.ResponseWriter, r *http.Request) {  q := r.URL.Query()  offset, _ := strconv.Atoi(q.Get("offset"))  limit, _ := strconv.Atoi(q.Get("limit"))  items, total, err := h.svc.List(r.Context(), offset, limit)  if err != nil {    mapDomainError(w, err)    return  }  resp := listResponse{Items: items}  resp.Page.Offset = offset  if limit == 0 { limit = 20 }  resp.Page.Limit = limit  resp.Page.Total = total  writeJSON(w, http.StatusOK, resp)}

Middlewares: logging, request id, timeouts y métricas básicas

Los middlewares se encadenan para aplicar políticas transversales. Incluiremos: (1) request id, (2) logging con duración y status, (3) timeout con context, (4) métricas simples por endpoint.

package httptransportimport (  "context"  "log"  "net/http"  "sync"  "time"  "github.com/google/uuid")type statusWriter struct {  http.ResponseWriter  status int}func (w *statusWriter) WriteHeader(code int) {  w.status = code  w.ResponseWriter.WriteHeader(code)}type Middleware func(http.Handler) http.Handlerfunc Chain(h http.Handler, m ...Middleware) http.Handler {  for i := len(m) - 1; i >= 0; i-- {    h = m[i](h)  }  return h}func RequestID() Middleware {  return func(next http.Handler) http.Handler {    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {      id := r.Header.Get("X-Request-Id")      if id == "" { id = uuid.NewString() }      w.Header().Set("X-Request-Id", id)      ctx := context.WithValue(r.Context(), "request_id", id)      next.ServeHTTP(w, r.WithContext(ctx))    })  }}func Timeout(d time.Duration) Middleware {  return func(next http.Handler) http.Handler {    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {      ctx, cancel := context.WithTimeout(r.Context(), d)      defer cancel()      next.ServeHTTP(w, r.WithContext(ctx))    })  }}func Logger() Middleware {  return func(next http.Handler) http.Handler {    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {      sw := &statusWriter{ResponseWriter: w, status: 200}      start := time.Now()      next.ServeHTTP(sw, r)      rid, _ := r.Context().Value("request_id").(string)      log.Printf("rid=%s method=%s path=%s status=%d dur=%s", rid, r.Method, r.URL.Path, sw.status, time.Since(start))    })  }}type Metrics struct {  mu     sync.Mutex  counts map[string]int  latSum map[string]time.Duration}func NewMetrics() *Metrics {  return &Metrics{counts: map[string]int{}, latSum: map[string]time.Duration{}}}func (m *Metrics) Middleware() Middleware {  return func(next http.Handler) http.Handler {    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {      start := time.Now()      next.ServeHTTP(w, r)      key := r.Method + " " + r.URL.Path      m.mu.Lock()      m.counts[key]++      m.latSum[key] += time.Since(start)      m.mu.Unlock()    })  }}func (m *Metrics) Handler(w http.ResponseWriter, r *http.Request) {  m.mu.Lock()  defer m.mu.Unlock()  type row struct {    Endpoint string `json:"endpoint"`    Count    int    `json:"count"`    AvgMs    int64  `json:"avg_ms"`  }  rows := make([]row, 0, len(m.counts))  for k, c := range m.counts {    avg := time.Duration(0)    if c > 0 { avg = m.latSum[k] / time.Duration(c) }    rows = append(rows, row{Endpoint: k, Count: c, AvgMs: avg.Milliseconds()})  }  writeJSON(w, http.StatusOK, map[string]any{"items": rows})}

Servidor: registro de rutas y composición

Componemos dependencias (repo → service → handlers) y registramos rutas. También exponemos GET /metrics.

package httptransportimport (  "net/http"  "time"  "yourmodule/internal/repository"  "yourmodule/internal/service")func NewServer() http.Handler {  repo := repository.NewMemoryTaskRepo()  svc := service.NewTaskService(repo)  h := NewTaskHandlers(svc)  metrics := NewMetrics()  mux := http.NewServeMux()  mux.HandleFunc("GET /tasks", h.List)  mux.HandleFunc("POST /tasks", h.Create)  mux.HandleFunc("GET /tasks/{id}", h.Get)  mux.HandleFunc("PUT /tasks/{id}", h.Update)  mux.HandleFunc("DELETE /tasks/{id}", h.Delete)  mux.HandleFunc("GET /metrics", metrics.Handler)  return Chain(mux, RequestID(), Timeout(2*time.Second), metrics.Middleware(), Logger())}

Ejecutar el servidor (main)

package mainimport (  "log"  "net/http"  "yourmodule/internal/transport/httptransport")func main() {  h := httptransport.NewServer()  log.Println("listening on :8080")  log.Fatal(http.ListenAndServe(":8080", h))}

Guía práctica paso a paso

1) Define el contrato y endpoints

  • Especifica rutas, métodos, request/response JSON y códigos HTTP.
  • Define un formato de error estable (code, message, details).

2) Crea el dominio y errores

  • Entidad Task y errores de dominio (ErrNotFound, ErrValidation).
  • Evita dependencias HTTP en esta capa.

3) Implementa repositorio y servicio

  • Repositorio con interfaz para facilitar tests y cambios de almacenamiento.
  • Servicio con validación y reglas; el handler no debe contener lógica de negocio.

4) Implementa handlers y utilidades HTTP

  • Decodifica JSON, valida lo mínimo (p. ej. JSON bien formado), delega al servicio.
  • Responde con 201 al crear, 204 al borrar, y errores consistentes.

5) Añade middlewares

  • Timeout con context.WithTimeout para evitar requests colgados.
  • Logging con status y duración.
  • Métricas básicas por endpoint (conteo y latencia promedio).

6) Prueba con curl

curl -s -X POST http://localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title":"Aprender Go"}'curl -s http://localhost:8080/tasks?offset=0&limit=10curl -s http://localhost:8080/tasks/<id>curl -s -X PUT http://localhost:8080/tasks/<id> -H 'Content-Type: application/json' -d '{"title":"Aprender Go bien","done":true}'curl -i -X DELETE http://localhost:8080/tasks/<id>curl -s http://localhost:8080/metrics

Pruebas de integración con httptest

Una prueba de integración valida el sistema completo (router + middlewares + handlers + servicio + repo). Aquí probamos el flujo CRUD y la paginación.

package httptransport_testimport (  "bytes"  "encoding/json"  "net/http"  "net/http/httptest"  "testing"  "yourmodule/internal/transport/httptransport")func TestTasksCRUD(t *testing.T) {  srv := httptest.NewServer(httptransport.NewServer())  defer srv.Close()  // Create  createBody := []byte(`{"title":"T1"}`)  resp, err := http.Post(srv.URL+"/tasks", "application/json", bytes.NewReader(createBody))  if err != nil { t.Fatal(err) }  if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201 got %d", resp.StatusCode) }  var created map[string]any  _ = json.NewDecoder(resp.Body).Decode(&created)  _ = resp.Body.Close()  id, _ := created["id"].(string)  if id == "" { t.Fatalf("expected id") }  // Get  resp, err = http.Get(srv.URL + "/tasks/" + id)  if err != nil { t.Fatal(err) }  if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 got %d", resp.StatusCode) }  _ = resp.Body.Close()  // List (pagination)  resp, err = http.Get(srv.URL + "/tasks?offset=0&limit=10")  if err != nil { t.Fatal(err) }  if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 got %d", resp.StatusCode) }  var list map[string]any  _ = json.NewDecoder(resp.Body).Decode(&list)  _ = resp.Body.Close()  if list["items"] == nil { t.Fatalf("expected items") }  // Update  client := &http.Client{}  upd := []byte(`{"title":"T1-upd","done":true}`)  req, _ := http.NewRequest(http.MethodPut, srv.URL+"/tasks/"+id, bytes.NewReader(upd))  req.Header.Set("Content-Type", "application/json")  resp, err = client.Do(req)  if err != nil { t.Fatal(err) }  if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 got %d", resp.StatusCode) }  _ = resp.Body.Close()  // Delete  req, _ = http.NewRequest(http.MethodDelete, srv.URL+"/tasks/"+id, nil)  resp, err = client.Do(req)  if err != nil { t.Fatal(err) }  if resp.StatusCode != http.StatusNoContent { t.Fatalf("expected 204 got %d", resp.StatusCode) }  _ = resp.Body.Close()  // Get after delete => 404  resp, err = http.Get(srv.URL + "/tasks/" + id)  if err != nil { t.Fatal(err) }  if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 got %d", resp.StatusCode) }  _ = resp.Body.Close()}

Tabla de referencia: códigos de estado recomendados

EscenarioStatusRespuesta
Crear recurso201 CreatedJSON del recurso creado
Listar/Obtener200 OKJSON con datos
Actualizar200 OKJSON actualizado
Borrar204 No ContentSin body
JSON inválido400 Bad RequestError envelope
Validación de negocio400 Bad RequestError envelope
No encontrado404 Not FoundError envelope
Conflicto (duplicado, versión)409 ConflictError envelope
Fallo inesperado500 Internal Server ErrorError envelope

Ahora responde el ejercicio sobre el contenido:

¿Cuál es el objetivo principal de organizar una API HTTP en capas (transporte/HTTP, servicio, repositorio y dominio) y centralizar el manejo de errores JSON?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Separar transporte, servicio, repositorio y dominio evita mezclar HTTP con reglas de negocio, facilita pruebas y cambios. Centralizar la escritura de JSON y el mapeo de errores asegura un contrato estable y respuestas coherentes.

Siguiente capítulo

Herramientas de línea de comandos en Go: automatización y ergonomía

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
67%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.