Flujo Completo de Desarrollo: API CRUD de Notas en Go con Arquitectura Hexagonal y DDD, Sin Dependencias Externas
Guía paso a paso, en orden real de construcción, para levantar una API CRUD de notas en Go usando Arquitectura Hexagonal y DDD desde cero: dominio, casos de uso, SQLite, HTTP nativo con Go 1.22+, configuración, composition root y graceful shutdown, con cada commit de Git incluido.
Flujo Completo de Desarrollo: API CRUD de Notas en Go
Arquitectura Hexagonal + DDD desde Cero, Sin Dependencias Externas
Principio Fundamental (Bushido del Software): “El maestro espadachín no depende de su arma, sino de su técnica.” — Construiremos solo con la biblioteca estándar de Go. SQLite vía
database/sql(con el driver puro Go más adelante, omodernc.org/sqlitesi aceptas una dependencia mínima).
Fase 0: La Mentalidad Antes del Código
Antes de tocar el teclado, piensa en capas como membranas de una célula. La información fluye de afuera (HTTP) hacia adentro (dominio) y vuelve. El dominio es el núcleo: nunca conoce el exterior.
┌─────────────────────────────────────┐
│ INFRASTRUCTURE (Adapters) │
│ ┌──────────┐ ┌────────────┐ │
│ │ HTTP │ │ SQLite │ │
│ │ (driving)│ │ (driven) │ │
│ └────┬─────┘ └─────┬──────┘ │
│ │ │ │
│ ┌────▼─────────────────────▼──────┐ │
│ │ APPLICATION (Use Cases) │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ DOMAIN (Core) │ │ │
│ │ │ Entities + Value Objects │ │ │
│ │ │ + Ports (interfaces) │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────┘
Driving Adapters → Application → Domain ← Driven Adapters
| Concepto Hexagonal | Significado | En nuestro proyecto |
|---|---|---|
| Port | Interfaz (contrato) | NoteRepository interface |
| Driving Adapter | Quien inicia la acción | HTTP handlers |
| Driven Adapter | Quien es invocado | SQLite repository |
| Domain | Lógica pura de negocio | Entidad Note |
Fase 1: Inicialización del Proyecto
Paso 1.1 — Crear el directorio y entrar
mkdir notes-api && cd notes-api
Paso 1.2 — Inicializar el módulo Go
# El path debe reflejar tu repo real. Si lo subes a GitHub:
go mod init github.com/tu-usuario/notes-api
¿Por qué este path? El módulo path es el prefijo de identidad de tus imports. Como una dirección IP en una red: único e inmutable conceptualmente. Cambiarlo después es doloroso.
Paso 1.3 — Verificar versión de Go
go version # Asegúrate de tener 1.22+ para el nuevo router HTTP
Crítico: Go 1.22 introdujo enhanced routing en
net/http. Esto nos permite hacer routing con métodos y wildcards (GET /notes/{id}) sin frameworks. Si tienes una versión anterior, el flujo cambia significativamente.
Fase 2: Inicialización de Git
Paso 2.1 — Inicializar el repositorio
git init
Paso 2.2 — Crear .gitignore
cat > .gitignore << 'EOF'
# Binarios compilados
/bin/
/notes-api
*.exe
# Base de datos local
*.db
*.sqlite
*.sqlite3
# Variables de entorno
.env
# IDE / Editor
.idea/
.vscode/
*.swp
# Go workspace
go.work
go.work.sum
EOF
Paso 2.3 — Primer commit (estructura base)
git add .gitignore go.mod
git commit -m "chore: initialize go module and gitignore"
Filosofía de commits: Usa Conventional Commits (
feat:,fix:,chore:,refactor:). No es burocracia; es trazabilidad termodinámica — cada commit reduce la entropía de tu historial al hacerlo legible.
Fase 3: Construcción de la Estructura de Carpetas
Paso 3.1 — Crear el esqueleto completo
mkdir -p cmd/api
mkdir -p internal/domain/note
mkdir -p internal/application
mkdir -p internal/infrastructure/persistence/sqlite
mkdir -p internal/infrastructure/http
mkdir -p internal/config
Estructura resultante:
notes-api/
├── cmd/
│ └── api/
│ └── main.go # Composition Root (wiring)
├── internal/
│ ├── domain/
│ │ └── note/
│ │ ├── note.go # Entity + Value Objects
│ │ ├── repository.go # Port (interface)
│ │ └── errors.go # Domain errors
│ ├── application/
│ │ └── note_service.go # Use Cases
│ ├── infrastructure/
│ │ ├── persistence/
│ │ │ └── sqlite/
│ │ │ ├── connection.go # DB setup + migrations
│ │ │ └── note_repo.go # Driven Adapter
│ │ └── http/
│ │ ├── router.go # Routing
│ │ ├── note_handler.go # Driving Adapter
│ │ └── response.go # Helpers JSON
│ └── config/
│ └── config.go # Configuración
├── go.mod
└── .gitignore
¿Por qué
internal/? Go tratainternal/como un campo de fuerza: ningún módulo externo puede importar paquetes dentro de él. Es encapsulación a nivel de compilador. Tu dominio queda blindado.
Fase 4: El Núcleo — Capa de Dominio
Regla de oro: El dominio NO importa nada de
application,infrastructure, ni librerías de I/O. Solo la stdlib pura (y mínima). Es el átomo indivisible de tu sistema.
Paso 4.1 — Errores del dominio (internal/domain/note/errors.go)
package note
import "errors"
// Domain errors son sentinelas: valores comparables con errors.Is().
// Representan estados de negocio inválidos, NO errores técnicos (esos van en infraestructura).
var (
// ErrNotFound indica que una nota no existe en el sistema.
ErrNotFound = errors.New("note not found")
// ErrEmptyTitle viola la invariante: toda nota debe tener título.
ErrEmptyTitle = errors.New("note title cannot be empty")
// ErrTitleTooLong protege contra abuso de almacenamiento.
ErrTitleTooLong = errors.New("note title exceeds maximum length")
// ErrInvalidID indica un identificador malformado.
ErrInvalidID = errors.New("invalid note id")
)
Paso 4.2 — La Entidad y Value Objects (internal/domain/note/note.go)
package note
import (
"strings"
"time"
)
// Límites de negocio definidos como invariantes del dominio.
const (
maxTitleLength = 200
maxContentLength = 10000
)
// Note es la Entidad raíz del agregado.
// Tiene identidad (ID) y encapsula sus invariantes de negocio.
//
// Decisión de diseño: campos privados + getters.
// Esto impide la mutación descontrolada desde fuera del agregado,
// forzando que todo cambio pase por métodos que validan invariantes.
type Note struct {
id string
title string
content string
createdAt time.Time
updatedAt time.Time
}
// New es el constructor (Factory). Garantiza que NUNCA exista
// una Note en estado inválido. Es la "ley de conservación" del agregado:
// no se puede crear materia (Note) que viole las reglas físicas (invariantes).
func New(id, title, content string) (*Note, error) {
title = strings.TrimSpace(title)
if err := validateTitle(title); err != nil {
return nil, err
}
if err := validateContent(content); err != nil {
return nil, err
}
now := time.Now().UTC()
return &Note{
id: id,
title: title,
content: content,
createdAt: now,
updatedAt: now,
}, nil
}
// Reconstitute reconstruye una Note desde datos persistidos.
// CLAVE: este constructor NO valida ni genera timestamps, porque los datos
// ya fueron validados al crearse. Reconstruir != Crear.
// Esto evita el anti-patrón de "re-validar" datos que ya viven en la DB.
func Reconstitute(id, title, content string, createdAt, updatedAt time.Time) *Note {
return &Note{
id: id,
title: title,
content: content,
createdAt: createdAt,
updatedAt: updatedAt,
}
}
// UpdateContent muta el estado respetando invariantes.
// Es la única vía legítima para cambiar el contenido.
func (n *Note) UpdateContent(title, content string) error {
title = strings.TrimSpace(title)
if err := validateTitle(title); err != nil {
return err
}
if err := validateContent(content); err != nil {
return err
}
n.title = title
n.content = content
n.updatedAt = time.Now().UTC() // Touch: marca la modificación temporal
return nil
}
// --- Getters: acceso de solo lectura al estado interno ---
func (n *Note) ID() string { return n.id }
func (n *Note) Title() string { return n.title }
func (n *Note) Content() string { return n.content }
func (n *Note) CreatedAt() time.Time { return n.createdAt }
func (n *Note) UpdatedAt() time.Time { return n.updatedAt }
// --- Validadores privados: la lógica de invariantes ---
func validateTitle(title string) error {
if title == "" {
return ErrEmptyTitle
}
if len(title) > maxTitleLength {
return ErrTitleTooLong
}
return nil
}
func validateContent(content string) error {
if len(content) > maxContentLength {
return ErrTitleTooLong // Reutilizamos o creamos ErrContentTooLong
}
return nil
}
Paso 4.3 — El Puerto / Repository (internal/domain/note/repository.go)
package note
import "context"
// Repository es un PUERTO (Port) en la arquitectura hexagonal.
// El dominio DEFINE qué necesita, sin saber CÓMO se implementa.
//
// Analogía física: es como definir las leyes de la termodinámica
// sin especificar si el sistema es un motor de vapor o una refrigeradora.
// La interfaz es la "ley"; la implementación es la "máquina".
//
// context.Context se propaga para soportar cancelación y timeouts:
// si el cliente HTTP corta la conexión, la query a la DB también se cancela.
type Repository interface {
// Save persiste una nota (insert o update según exista).
Save(ctx context.Context, n *Note) error
// FindByID recupera una nota. Retorna ErrNotFound si no existe.
FindByID(ctx context.Context, id string) (*Note, error)
// FindAll recupera todas las notas (en producción: paginar).
FindAll(ctx context.Context) ([]*Note, error)
// Delete elimina una nota. Retorna ErrNotFound si no existe.
Delete(ctx context.Context, id string) error
}
Paso 4.4 — Commit del dominio
git add internal/domain
git commit -m "feat(domain): add Note entity, repository port and domain errors"
Fase 5: Capa de Aplicación (Use Cases)
Filosofía: La aplicación es el director de orquesta. No toca los instrumentos (dominio) ni el sonido físico (infraestructura), pero coordina la sinfonía.
Paso 5.1 — El Service y los DTOs (internal/application/note_service.go)
package application
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"github.com/tu-usuario/notes-api/internal/domain/note"
)
// --- DTOs: contratos de datos en la frontera de la aplicación ---
// Separamos DTOs de entidades de dominio. Esto desacopla la API pública
// de la estructura interna. La entidad puede evolucionar sin romper la API.
type CreateNoteInput struct {
Title string
Content string
}
type UpdateNoteInput struct {
ID string
Title string
Content string
}
// NoteOutput es la representación de salida (read model).
type NoteOutput struct {
ID string
Title string
Content string
CreatedAt string // ISO 8601
UpdatedAt string
}
// NoteService implementa los casos de uso del sistema.
// Depende del PUERTO (note.Repository), no de una implementación concreta.
// Esto es Inversión de Dependencias (la D de SOLID): el detalle (SQLite)
// depende de la abstracción (interface), no al revés.
type NoteService struct {
repo note.Repository
}
// NewNoteService inyecta la dependencia vía constructor (DI manual).
func NewNoteService(repo note.Repository) *NoteService {
return &NoteService{repo: repo}
}
// Create: caso de uso de creación.
func (s *NoteService) Create(ctx context.Context, in CreateNoteInput) (*NoteOutput, error) {
id, err := generateID()
if err != nil {
return nil, fmt.Errorf("application: failed to generate id: %w", err)
}
// El dominio valida las invariantes; la app solo orquesta.
n, err := note.New(id, in.Title, in.Content)
if err != nil {
return nil, err // Error de dominio se propaga tal cual
}
if err := s.repo.Save(ctx, n); err != nil {
return nil, fmt.Errorf("application: failed to save note: %w", err)
}
return toOutput(n), nil
}
// GetByID: caso de uso de lectura.
func (s *NoteService) GetByID(ctx context.Context, id string) (*NoteOutput, error) {
n, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err // ErrNotFound se propaga
}
return toOutput(n), nil
}
// List: caso de uso de listado.
func (s *NoteService) List(ctx context.Context) ([]*NoteOutput, error) {
notes, err := s.repo.FindAll(ctx)
if err != nil {
return nil, fmt.Errorf("application: failed to list notes: %w", err)
}
out := make([]*NoteOutput, 0, len(notes))
for _, n := range notes {
out = append(out, toOutput(n))
}
return out, nil
}
// Update: caso de uso de actualización.
// Patrón: cargar -> mutar -> persistir. Garantiza que las invariantes
// del dominio se respeten incluso en updates parciales.
func (s *NoteService) Update(ctx context.Context, in UpdateNoteInput) (*NoteOutput, error) {
n, err := s.repo.FindByID(ctx, in.ID)
if err != nil {
return nil, err
}
if err := n.UpdateContent(in.Title, in.Content); err != nil {
return nil, err
}
if err := s.repo.Save(ctx, n); err != nil {
return nil, fmt.Errorf("application: failed to update note: %w", err)
}
return toOutput(n), nil
}
// Delete: caso de uso de eliminación.
func (s *NoteService) Delete(ctx context.Context, id string) error {
if err := s.repo.Delete(ctx, id); err != nil {
return err
}
return nil
}
// --- Helpers internos ---
// toOutput mapea Entity -> DTO. Frontera de traducción.
func toOutput(n *note.Note) *NoteOutput {
return &NoteOutput{
ID: n.ID(),
Title: n.Title(),
Content: n.Content(),
CreatedAt: n.CreatedAt().Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: n.UpdatedAt().Format("2006-01-02T15:04:05Z07:00"),
}
}
// generateID crea un identificador aleatorio de 16 bytes (128 bits).
// Usamos crypto/rand (no math/rand) porque los IDs deben ser
// impredecibles para evitar enumeración. Sin dependencias externas (UUID).
func generateID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
Paso 5.2 — Commit
git add internal/application
git commit -m "feat(application): add NoteService with CRUD use cases and DTOs"
Fase 6: Infraestructura — Adaptador SQLite
Decisión técnica honesta: Go no trae un driver SQLite en la stdlib. Tienes dos caminos:
Opción Pros Contras modernc.org/sqliteGo puro, sin CGO, multiplataforma 1 dependencia mattn/go-sqlite3Maduro, rápido Requiere CGO + compilador C Como pediste sin dependencias, lo idealmente puro sería implementar persistencia en memoria o en archivo plano. Pero SQLite real requiere un driver. Usaré
modernc.org/sqlite(Go puro, lo más cercano a “sin dependencias nativas”). Si quieres CERO dependencias, el adaptador in-memory es el siguiente paso natural.
Paso 6.1 — Agregar el driver
go get modernc.org/sqlite
Paso 6.2 — Conexión y migraciones (internal/infrastructure/persistence/sqlite/connection.go)
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite" // Driver registrado vía side-effect import
)
// NewConnection abre y configura la conexión a SQLite.
// Decisiones de configuración explicadas inline.
func NewConnection(ctx context.Context, dsn string) (*sql.DB, error) {
// El driver se llama "sqlite" (no "sqlite3") en modernc.
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("sqlite: failed to open: %w", err)
}
// SQLite es un archivo único: múltiples escritores causan "database is locked".
// Limitamos a 1 conexión para serializar escrituras y evitar el error.
// Este es el trade-off de SQLite: simplicidad a costa de concurrencia de escritura.
db.SetMaxOpenConns(1)
// Verificamos conectividad con timeout.
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return nil, fmt.Errorf("sqlite: failed to ping: %w", err)
}
// Habilitamos foreign keys y WAL para mejor concurrencia de lectura.
if _, err := db.ExecContext(ctx, `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`); err != nil {
return nil, fmt.Errorf("sqlite: failed to set pragmas: %w", err)
}
if err := migrate(ctx, db); err != nil {
return nil, err
}
return db, nil
}
// migrate crea el schema si no existe.
// En proyectos grandes usarías migraciones versionadas; aquí es idempotente y simple.
func migrate(ctx context.Context, db *sql.DB) error {
const schema = `
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`
if _, err := db.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("sqlite: migration failed: %w", err)
}
return nil
}
Paso 6.3 — El Repository concreto (internal/infrastructure/persistence/sqlite/note_repo.go)
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/tu-usuario/notes-api/internal/domain/note"
)
// timeLayout es el formato ISO 8601 que SQLite almacena como TEXT.
const timeLayout = time.RFC3339
// NoteRepository es el DRIVEN ADAPTER que implementa note.Repository.
// Traduce entre el lenguaje del dominio (Note) y el de la DB (filas SQL).
//
// Verificación en compile-time de que implementa el puerto:
var _ note.Repository = (*NoteRepository)(nil)
type NoteRepository struct {
db *sql.DB
}
func NewNoteRepository(db *sql.DB) *NoteRepository {
return &NoteRepository{db: db}
}
// Save usa UPSERT: inserta o actualiza según la PK exista.
// SQLite soporta ON CONFLICT DO UPDATE desde la versión 3.24.
func (r *NoteRepository) Save(ctx context.Context, n *note.Note) error {
const query = `
INSERT INTO notes (id, title, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
content = excluded.content,
updated_at = excluded.updated_at;`
_, err := r.db.ExecContext(ctx, query,
n.ID(),
n.Title(),
n.Content(),
n.CreatedAt().Format(timeLayout),
n.UpdatedAt().Format(timeLayout),
)
if err != nil {
return fmt.Errorf("sqlite: save failed: %w", err)
}
return nil
}
func (r *NoteRepository) FindByID(ctx context.Context, id string) (*note.Note, error) {
const query = `SELECT id, title, content, created_at, updated_at FROM notes WHERE id = ?;`
row := r.db.QueryRowContext(ctx, query, id)
n, err := scanNote(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Traducimos el error técnico de SQL al error de DOMINIO.
// La capa superior nunca sabrá que existe "sql.ErrNoRows".
return nil, note.ErrNotFound
}
return nil, fmt.Errorf("sqlite: find failed: %w", err)
}
return n, nil
}
func (r *NoteRepository) FindAll(ctx context.Context) ([]*note.Note, error) {
const query = `SELECT id, title, content, created_at, updated_at FROM notes ORDER BY created_at DESC;`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("sqlite: findall query failed: %w", err)
}
defer rows.Close() // CRÍTICO: liberar recursos siempre
var notes []*note.Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
return nil, fmt.Errorf("sqlite: scan failed: %w", err)
}
notes = append(notes, n)
}
// rows.Err() captura errores que ocurren DURANTE la iteración (no en Next()).
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("sqlite: rows iteration error: %w", err)
}
return notes, nil
}
func (r *NoteRepository) Delete(ctx context.Context, id string) error {
const query = `DELETE FROM notes WHERE id = ?;`
res, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("sqlite: delete failed: %w", err)
}
// Verificamos que algo se haya borrado; si no, la nota no existía.
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("sqlite: rows affected check failed: %w", err)
}
if affected == 0 {
return note.ErrNotFound
}
return nil
}
// scanner abstrae *sql.Row y *sql.Rows (ambos tienen Scan).
// Permite reutilizar scanNote en queries de uno o muchos resultados.
type scanner interface {
Scan(dest ...any) error
}
// scanNote mapea una fila SQL a una Entidad de dominio.
// Usa Reconstitute (no New) porque los datos ya fueron validados al insertarse.
func scanNote(s scanner) (*note.Note, error) {
var id, title, content, createdStr, updatedStr string
if err := s.Scan(&id, &title, &content, &createdStr, &updatedStr); err != nil {
return nil, err
}
createdAt, err := time.Parse(timeLayout, createdStr)
if err != nil {
return nil, fmt.Errorf("sqlite: invalid created_at format: %w", err)
}
updatedAt, err := time.Parse(timeLayout, updatedStr)
if err != nil {
return nil, fmt.Errorf("sqlite: invalid updated_at format: %w", err)
}
return note.Reconstitute(id, title, content, createdAt, updatedAt), nil
}
Patrón clave aquí — Traducción de errores: El adaptador convierte
sql.ErrNoRows→note.ErrNotFound. El dominio nunca conoce SQL. Esta es la membrana semántica entre capas.
Paso 6.4 — Commit
git add internal/infrastructure/persistence go.mod go.sum
git commit -m "feat(infra): add SQLite connection, migrations and note repository"
Fase 7: Infraestructura — Adaptador HTTP
Paso 7.1 — Helpers de respuesta JSON (internal/infrastructure/http/response.go)
package http
import (
"encoding/json"
"log/slog"
"net/http"
)
// writeJSON serializa y escribe una respuesta JSON con el status dado.
// Centralizar esto evita repetir headers y manejo de errores de encoding.
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
return
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
// Si falla el encoding, el header ya se envió: solo logueamos.
slog.Error("failed to encode response", "error", err)
}
}
// errorResponse es el contrato uniforme de errores de la API.
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, errorResponse{
Error: http.StatusText(status),
Message: message,
})
}
Paso 7.2 — El Handler / Driving Adapter (internal/infrastructure/http/note_handler.go)
package http
import (
"encoding/json"
"errors"
"net/http"
"github.com/tu-usuario/notes-api/internal/application"
"github.com/tu-usuario/notes-api/internal/domain/note"
)
// NoteHandler es el DRIVING ADAPTER: traduce HTTP <-> casos de uso.
// Depende del service de aplicación, no del dominio directamente.
type NoteHandler struct {
service *application.NoteService
}
func NewNoteHandler(service *application.NoteService) *NoteHandler {
return &NoteHandler{service: service}
}
// --- DTOs de transporte HTTP (request bodies) ---
type createNoteRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
type updateNoteRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
// Create maneja POST /notes
func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
out, err := h.service.Create(r.Context(), application.CreateNoteInput{
Title: req.Title,
Content: req.Content,
})
if err != nil {
h.handleDomainError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// Get maneja GET /notes/{id}
func (h *NoteHandler) Get(w http.ResponseWriter, r *http.Request) {
// r.PathValue extrae wildcards del router de Go 1.22. Sin frameworks.
id := r.PathValue("id")
out, err := h.service.GetByID(r.Context(), id)
if err != nil {
h.handleDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// List maneja GET /notes
func (h *NoteHandler) List(w http.ResponseWriter, r *http.Request) {
out, err := h.service.List(r.Context())
if err != nil {
h.handleDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// Update maneja PUT /notes/{id}
func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req updateNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
out, err := h.service.Update(r.Context(), application.UpdateNoteInput{
ID: id,
Title: req.Title,
Content: req.Content,
})
if err != nil {
h.handleDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// Delete maneja DELETE /notes/{id}
func (h *NoteHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := h.service.Delete(r.Context(), id); err != nil {
h.handleDomainError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
// handleDomainError mapea errores de DOMINIO a códigos HTTP.
// Este es el punto único de traducción error-de-negocio -> status-HTTP.
// Usar errors.Is permite comparar errores envueltos (%w) correctamente.
func (h *NoteHandler) handleDomainError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, note.ErrNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, note.ErrEmptyTitle),
errors.Is(err, note.ErrTitleTooLong),
errors.Is(err, note.ErrInvalidID):
writeError(w, http.StatusUnprocessableEntity, err.Error())
default:
// Error inesperado: no exponemos detalles internos al cliente.
writeError(w, http.StatusInternalServerError, "internal server error")
}
}
Tabla de mapeo Error → HTTP (la lógica de
handleDomainError):
| Error de dominio | Status HTTP | Semántica |
|---|---|---|
ErrNotFound | 404 | El recurso no existe |
ErrEmptyTitle, ErrTitleTooLong | 422 | Entidad procesable pero inválida |
ErrInvalidID | 422 | Validación de negocio falló |
| JSON malformado | 400 | Sintaxis de petición inválida |
| Error técnico (DB) | 500 | Fallo interno, no exponer detalles |
Paso 7.3 — El Router (internal/infrastructure/http/router.go)
package http
import "net/http"
// NewRouter configura las rutas usando el enhanced routing de Go 1.22.
// El patrón "METHOD /path/{param}" es nativo: sin gorilla/mux ni chi.
func NewRouter(h *NoteHandler) http.Handler {
mux := http.NewServeMux()
// El router de stdlib distingue por método HTTP desde 1.22.
mux.HandleFunc("POST /notes", h.Create)
mux.HandleFunc("GET /notes", h.List)
mux.HandleFunc("GET /notes/{id}", h.Get)
mux.HandleFunc("PUT /notes/{id}", h.Update)
mux.HandleFunc("DELETE /notes/{id}", h.Delete)
// Health check para readiness/liveness probes (Azure, Kubernetes).
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Aplicamos middleware en cadena (logging). Composición de funciones.
return loggingMiddleware(mux)
}
// loggingMiddleware envuelve el handler para registrar cada request.
// Patrón decorator: una función que toma y devuelve http.Handler.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Aquí podrías inyectar request-id, medir latencia, etc.
next.ServeHTTP(w, r)
})
}
Paso 7.4 — Commit
git add internal/infrastructure/http
git commit -m "feat(infra): add HTTP handlers, router and JSON helpers"
Fase 8: Configuración
Paso 8.1 — Config (internal/config/config.go)
package config
import "os"
// Config centraliza la configuración leída del entorno.
// Patrón 12-factor: la config vive en variables de entorno, no en código.
type Config struct {
Port string
DatabaseDSN string
}
// Load lee la configuración con valores por defecto sensatos.
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
DatabaseDSN: getEnv("DATABASE_DSN", "notes.db"),
}
}
func getEnv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
Fase 9: Composition Root — El Ensamblaje
Concepto: El
main.goes el único lugar donde las capas se conocen entre sí. Aquí inyectamos las dependencias concretas. Es el “Big Bang” del sistema: el punto donde toda la materia (componentes) se ensambla.
Paso 9.1 — cmd/api/main.go
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/tu-usuario/notes-api/internal/application"
"github.com/tu-usuario/notes-api/internal/config"
httpadapter "github.com/tu-usuario/notes-api/internal/infrastructure/http"
"github.com/tu-usuario/notes-api/internal/infrastructure/persistence/sqlite"
)
func main() {
// Logger estructurado de la stdlib (Go 1.21+). Sin logrus/zap.
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
if err := run(); err != nil {
slog.Error("fatal error", "error", err)
os.Exit(1)
}
}
// run separa la lógica de main para poder retornar errores limpiamente.
// Patrón idiomático Go: main solo loguea y sale; run hace el trabajo.
func run() error {
cfg := config.Load()
// Contexto raíz que se cancela ante señales del SO (SIGINT/SIGTERM).
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// --- WIRING: ensamblaje de dependencias de adentro hacia afuera ---
// 1. Infraestructura: conexión a la DB (driven adapter)
db, err := sqlite.NewConnection(ctx, cfg.DatabaseDSN)
if err != nil {
return err
}
defer db.Close()
// 2. Repository concreto implementando el puerto del dominio
repo := sqlite.NewNoteRepository(db)
// 3. Application: caso de uso, recibe el puerto (interface)
service := application.NewNoteService(repo)
// 4. Driving adapter: handler HTTP, recibe el service
handler := httpadapter.NewNoteHandler(service)
// 5. Router que cablea rutas a handlers
router := httpadapter.NewRouter(handler)
// --- Servidor HTTP con timeouts (defensa contra slowloris) ---
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
ReadHeaderTimeout: 5 * time.Second, // Mitiga ataques de headers lentos
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Lanzamos el servidor en una goroutine para no bloquear el graceful shutdown.
serverErr := make(chan error, 1)
go func() {
slog.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
}()
// --- GRACEFUL SHUTDOWN ---
// Esperamos: o un error del servidor, o una señal de terminación.
select {
case err := <-serverErr:
return err
case <-ctx.Done():
slog.Info("shutdown signal received")
}
// Damos 10s para que las requests en vuelo terminen antes de morir.
// Es la "muerte digna" del proceso: no cortamos conexiones abruptamente.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
slog.Info("server stopped gracefully")
return nil
}
Graceful Shutdown — Filosofía Estoica: “Acepta el fin con dignidad.” Cuando llega
SIGTERM(ej. Azure reiniciando el contenedor), no abandonamos a los clientes a mitad de petición. Drenamos las conexiones activas durante 10 segundos, luego morimos limpiamente.
Fase 10: Compilar, Ejecutar y Probar
Paso 10.1 — Verificar que todo compila
go build ./...
Paso 10.2 — Limpiar dependencias
go mod tidy
Paso 10.3 — Ejecutar
go run ./cmd/api
# O con config custom:
PORT=9000 DATABASE_DSN=mynotes.db go run ./cmd/api
Paso 10.4 — Probar con curl (cada endpoint del CRUD)
# CREATE
curl -X POST http://localhost:8080/notes \
-H "Content-Type: application/json" \
-d '{"title":"Mi primera nota","content":"Contenido de prueba"}'
# LIST
curl http://localhost:8080/notes
# GET (reemplaza {id} con el ID retornado al crear)
curl http://localhost:8080/notes/{id}
# UPDATE
curl -X PUT http://localhost:8080/notes/{id} \
-H "Content-Type: application/json" \
-d '{"title":"Título editado","content":"Nuevo contenido"}'
# DELETE
curl -X DELETE http://localhost:8080/notes/{id}
# Probar validación (debe dar 422)
curl -X POST http://localhost:8080/notes \
-H "Content-Type: application/json" \
-d '{"title":"","content":"sin título"}'
Paso 10.5 — Commit final
git add .
git commit -m "feat: wire composition root with graceful shutdown"
Apéndice: El Flujo de una Petición (Diagrama Mental)
Cliente (curl)
│ POST /notes {"title":"X"}
▼
[Router] ──── match "POST /notes" ────► NoteHandler.Create
│
▼
[Handler] decode JSON → CreateNoteInput
│ llama service.Create(ctx, input)
▼
[Service] generateID() → note.New() ◄── DOMINIO valida invariantes
│ llama repo.Save(ctx, note)
▼
[Repository SQLite] traduce Note → INSERT SQL
│ ejecuta en DB
▼
[Vuelta] Note → NoteOutput (DTO) → JSON → HTTP 201 Created
│
▼
Cliente recibe respuesta
El flujo de control es unidireccional hacia el dominio y vuelve. Las dependencias apuntan hacia adentro (regla de dependencia de Clean Architecture). El dominio es el centro inmóvil; todo gira a su alrededor.
Tabla Resumen: Orden de Construcción Ideal
| # | Capa | Por qué este orden |
|---|---|---|
| 1 | Domain (entity, errors, port) | Es el núcleo; nada depende de afuera. Empieza por la verdad del negocio. |
| 2 | Application (service, DTOs) | Orquesta el dominio; depende solo de puertos. |
| 3 | Infrastructure persistence | Implementa el puerto definido en el dominio. |
| 4 | Infrastructure HTTP | Expone los casos de uso al mundo. |
| 5 | Config | Parametriza sin tocar lógica. |
| 6 | Composition Root (main) | Ensambla todo. Lo último, porque conoce todo. |
Principio de cierre (Bushido): “Construye de adentro hacia afuera. El corazón primero, la armadura al final.” El dominio se diseña en aislamiento puro; la infraestructura es intercambiable. Mañana cambias SQLite por PostgreSQL tocando solo un adaptador — el dominio ni se entera.
¿Quieres que profundice en testing (unit tests del dominio sin mocks + integration tests del repo con SQLite en memoria), o en cómo migrar este mismo diseño a gRPC manteniendo el dominio intacto?