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í:
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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
Tasky 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
201al crear,204al borrar, y errores consistentes.
5) Añade middlewares
- Timeout con
context.WithTimeoutpara 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/metricsPruebas 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
| Escenario | Status | Respuesta |
|---|---|---|
| Crear recurso | 201 Created | JSON del recurso creado |
| Listar/Obtener | 200 OK | JSON con datos |
| Actualizar | 200 OK | JSON actualizado |
| Borrar | 204 No Content | Sin body |
| JSON inválido | 400 Bad Request | Error envelope |
| Validación de negocio | 400 Bad Request | Error envelope |
| No encontrado | 404 Not Found | Error envelope |
| Conflicto (duplicado, versión) | 409 Conflict | Error envelope |
| Fallo inesperado | 500 Internal Server Error | Error envelope |