Flujo Completo de Desarrollo: API CRUD de Notas en Go con Arquitectura Hexagonal y DDD, Sin Dependencias Externas

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.

Por Omar Flores

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, o modernc.org/sqlite si 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 HexagonalSignificadoEn nuestro proyecto
PortInterfaz (contrato)NoteRepository interface
Driving AdapterQuien inicia la acciónHTTP handlers
Driven AdapterQuien es invocadoSQLite repository
DomainLógica pura de negocioEntidad 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 trata internal/ 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ónProsContras
modernc.org/sqliteGo puro, sin CGO, multiplataforma1 dependencia
mattn/go-sqlite3Maduro, rápidoRequiere 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.ErrNoRowsnote.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 dominioStatus HTTPSemántica
ErrNotFound404El recurso no existe
ErrEmptyTitle, ErrTitleTooLong422Entidad procesable pero inválida
ErrInvalidID422Validación de negocio falló
JSON malformado400Sintaxis de petición inválida
Error técnico (DB)500Fallo 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.go es 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

#CapaPor qué este orden
1Domain (entity, errors, port)Es el núcleo; nada depende de afuera. Empieza por la verdad del negocio.
2Application (service, DTOs)Orquesta el dominio; depende solo de puertos.
3Infrastructure persistenceImplementa el puerto definido en el dominio.
4Infrastructure HTTPExpone los casos de uso al mundo.
5ConfigParametriza sin tocar lógica.
6Composition 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?

Artículos relacionados

Por relevancia