Go 1.27, Gin y SQLite: Notas con Arquitectura Hexagonal Paso a Paso
Guía paso a paso para construir una API REST de notas en Go 1.27, Gin, SQLite y Arquitectura Hexagonal + DDD. Código limpio, tests y Docker.
Contenido
Por Qué Este Proyecto
No necesitas un proyecto gigante para aprender arquitectura hexagonal. Necesitas uno pequeño, real, y completo — donde cada capa exista por una razón que puedas tocar con las manos.
Esta guía construye una API de notas. Sí, otra vez notas. Pero esta vez el objetivo no es la app — es el camino. Vas a escribir tú mismo cada archivo, en orden, entendiendo por qué existe antes de crearlo. Al final tendrás una API REST completa, corriendo en SQLite sin una sola línea de CGO, servida con Gin, organizada en dominio, aplicación y adaptadores, con tests reales y un Dockerfile que funciona a la primera.
Nada de frameworks mágicos. Nada de ORMs que ocultan el SQL. Nada de dependencias que no puedas explicar. Go 1.27, la librería estándar moderna (log/slog, cmp, context, errors.Is), y solo dos paquetes externos: gin para HTTP y modernc.org/sqlite para persistencia.
Qué Vamos a Construir
Una API CRUD de notas con:
- Crear, leer, listar, actualizar y eliminar notas
- Marcar notas como completadas (
done) - Filtrar por texto y por estado, con paginación simple
- Persistencia en SQLite, embebida, sin servidor externo
- Arquitectura Hexagonal: el dominio no sabe que existen Gin ni SQLite
- Tests de dominio y tests de integración HTTP
- Un
Dockerfilemulti-stage sin CGO, con imagen finaldistroless
Requisitos antes de empezar:
go version
# go version go1.27.1 linux/amd64 (o superior)
Arquitectura Hexagonal en Cinco Minutos
La idea central: el dominio (las reglas de negocio) vive en el centro, aislado. No importa nada de fuera. Todo lo demás — HTTP, SQLite — son adaptadores que se conectan al dominio a través de puertos (interfaces).
graph LR
subgraph "Adaptador de Entrada"
A["Gin HTTP Handler"]
end
subgraph "Núcleo"
B["Puerto de Entrada<br/>NoteService"]
C["Aplicación<br/>Casos de Uso"]
D["Dominio<br/>Note + Reglas"]
E["Puerto de Salida<br/>NoteRepository"]
end
subgraph "Adaptador de Salida"
F["SQLite Repository"]
end
A -->|"llama"| B
B --> C
C --> D
C -->|"usa"| E
E -.->|"implementado por"| F
Cuatro capas, cada una con un trabajo:
- Dominio — el tipo
Note, sus reglas de validación, sus errores. No importa nada externo. - Aplicación — orquesta el dominio: crea, busca, actualiza. Define el puerto de salida (
NoteRepository) que necesita, sin saber quién lo implementa. - Adaptador de entrada — Gin traduce peticiones HTTP en llamadas a la aplicación.
- Adaptador de salida — SQLite implementa
NoteRepositorycon SQL real.
Si mañana cambias SQLite por PostgreSQL, reescribes un archivo. El dominio y la aplicación no se enteran. Ese es el punto entero del ejercicio.
Preparando el Proyecto
Crea la carpeta y el módulo:
mkdir notesapi && cd notesapi
go mod init notesapi
Instala las dos únicas dependencias externas:
go get github.com/gin-gonic/gin@latest
go get modernc.org/sqlite@latest
go mod tidy
modernc.org/sqlite es una reimplementación de SQLite compilada a Go puro (transpilada desde C, sin CGO). Esto importa: significa que compilas la API sin gcc, sin musl-dev, sin dolor de cabeza en Docker. Es la elección “moderna” frente al clásico mattn/go-sqlite3, que sí requiere CGO.
Crea la estructura de carpetas:
mkdir -p cmd/api
mkdir -p internal/domain
mkdir -p internal/application
mkdir -p internal/adapters/httpapi
mkdir -p internal/adapters/sqlite
mkdir -p internal/config
Al terminar la guía, el árbol se verá así:
notesapi/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── domain/
│ │ ├── note.go
│ │ ├── errors.go
│ │ ├── repository.go
│ │ └── note_test.go
│ ├── application/
│ │ ├── ports.go
│ │ └── note_service.go
│ ├── adapters/
│ │ ├── httpapi/
│ │ │ ├── dto.go
│ │ │ ├── handler.go
│ │ │ ├── handler_test.go
│ │ │ └── router.go
│ │ └── sqlite/
│ │ ├── db.go
│ │ └── note_repository.go
│ └── config/
│ └── config.go
├── go.mod
├── go.sum
├── Dockerfile
├── docker-compose.yml
└── Makefile
Paso 1: El Dominio — Note y Sus Reglas
Empieza siempre por el centro. Antes de pensar en HTTP o en SQL, define qué es una nota en el lenguaje del negocio.
internal/domain/errors.go
package domain
import "errors"
var (
ErrEmptyTitle = errors.New("title cannot be empty")
ErrTitleTooLong = errors.New("title exceeds 120 characters")
ErrNotFound = errors.New("note not found")
)
internal/domain/note.go
package domain
import (
"crypto/rand"
"encoding/hex"
"strings"
"time"
)
type Note struct {
ID string
Title string
Content string
Done bool
CreatedAt time.Time
UpdatedAt time.Time
}
func NewNote(title, content string) (*Note, error) {
title = strings.TrimSpace(title)
if err := validateTitle(title); err != nil {
return nil, err
}
now := time.Now().UTC()
return &Note{
ID: newID(),
Title: title,
Content: strings.TrimSpace(content),
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (n *Note) Rename(title, content string) error {
title = strings.TrimSpace(title)
if err := validateTitle(title); err != nil {
return err
}
n.Title = title
n.Content = strings.TrimSpace(content)
n.UpdatedAt = time.Now().UTC()
return nil
}
func (n *Note) SetDone(done bool) {
n.Done = done
n.UpdatedAt = time.Now().UTC()
}
func validateTitle(title string) error {
if title == "" {
return ErrEmptyTitle
}
if len(title) > 120 {
return ErrTitleTooLong
}
return nil
}
func newID() string {
buf := make([]byte, 12)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
Nota lo que no hay aquí: ni json:"...", ni sql:"...", ni un import de Gin o database/sql. Note no sabe que va a viajar por HTTP ni que va a terminar en una tabla. Solo sabe sus propias reglas: un título no puede estar vacío, no puede pasar de 120 caracteres, y cada cambio actualiza UpdatedAt.
El generador de IDs usa crypto/rand de la librería estándar — no necesitas un paquete UUID externo para esto.
internal/domain/repository.go — el puerto de salida
package domain
import "context"
type ListFilter struct {
Query string
Done *bool
Limit int
Offset int
}
type NoteRepository interface {
Save(ctx context.Context, note *Note) error
FindByID(ctx context.Context, id string) (*Note, error)
FindAll(ctx context.Context, filter ListFilter) ([]*Note, error)
Delete(ctx context.Context, id string) error
}
Esta interfaz vive en el dominio, pero la implementa la infraestructura. Es el contrato: “quien quiera persistir notas debe poder hacer esto”. El dominio define la necesidad; SQLite, más adelante, la satisface.
Paso 2: La Aplicación — Casos de Uso
La capa de aplicación orquesta. No valida reglas de negocio (eso ya lo hace Note), no sabe de SQL, no sabe de HTTP. Solo coordina: recibe una intención, llama al dominio, usa el repositorio.
internal/application/ports.go — el puerto de entrada
package application
import (
"context"
"notesapi/internal/domain"
)
type NoteService interface {
Create(ctx context.Context, title, content string) (*domain.Note, error)
Get(ctx context.Context, id string) (*domain.Note, error)
List(ctx context.Context, filter domain.ListFilter) ([]*domain.Note, error)
Update(ctx context.Context, id, title, content string) (*domain.Note, error)
SetDone(ctx context.Context, id string, done bool) (*domain.Note, error)
Delete(ctx context.Context, id string) error
}
Este es el puerto que el adaptador HTTP va a llamar. Fíjate que devuelve tipos de domain, no DTOs — la traducción a JSON pasa después, en el adaptador.
internal/application/note_service.go
package application
import (
"context"
"fmt"
"notesapi/internal/domain"
)
type noteService struct {
repo domain.NoteRepository
}
func NewNoteService(repo domain.NoteRepository) NoteService {
return ¬eService{repo: repo}
}
func (s *noteService) Create(ctx context.Context, title, content string) (*domain.Note, error) {
note, err := domain.NewNote(title, content)
if err != nil {
return nil, err
}
if err := s.repo.Save(ctx, note); err != nil {
return nil, fmt.Errorf("save note: %w", err)
}
return note, nil
}
func (s *noteService) Get(ctx context.Context, id string) (*domain.Note, error) {
return s.repo.FindByID(ctx, id)
}
func (s *noteService) List(ctx context.Context, filter domain.ListFilter) ([]*domain.Note, error) {
return s.repo.FindAll(ctx, filter)
}
func (s *noteService) Update(ctx context.Context, id, title, content string) (*domain.Note, error) {
note, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if err := note.Rename(title, content); err != nil {
return nil, err
}
if err := s.repo.Save(ctx, note); err != nil {
return nil, fmt.Errorf("save note: %w", err)
}
return note, nil
}
func (s *noteService) SetDone(ctx context.Context, id string, done bool) (*domain.Note, error) {
note, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
note.SetDone(done)
if err := s.repo.Save(ctx, note); err != nil {
return nil, fmt.Errorf("save note: %w", err)
}
return note, nil
}
func (s *noteService) Delete(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
noteService recibe domain.NoteRepository — la interfaz, no una implementación concreta. Todavía no existe SQLite en esta capa, y no debería. Eso es lo que hace este código testeable sin tocar una base de datos real: en un test, puedes pasarle cualquier cosa que cumpla la interfaz.
Paso 3: El Adaptador SQLite — Persistencia Real
Aquí es donde SQL finalmente aparece.
internal/adapters/sqlite/db.go
package sqlite
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
done INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes (updated_at DESC);
`
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA journal_mode = WAL;"); err != nil {
db.Close()
return nil, fmt.Errorf("enable wal: %w", err)
}
if _, err := db.Exec("PRAGMA busy_timeout = 5000;"); err != nil {
db.Close()
return nil, fmt.Errorf("set busy timeout: %w", err)
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("migrate schema: %w", err)
}
return db, nil
}
Tres decisiones que merecen explicación:
db.SetMaxOpenConns(1)— SQLite solo permite un escritor a la vez. Forzar una sola conexión evita errores dedatabase is lockedbajo carga concurrente, y además hace que:memory:se comporte de forma predecible en tests (cada conexión nueva a:memory:es una base de datos distinta y vacía; con una sola conexión, todos comparten la misma).PRAGMA journal_mode = WAL— modo Write-Ahead Logging, mejor concurrencia de lecturas mientras se escribe.PRAGMA busy_timeout = 5000— si SQLite está ocupado, espera 5 segundos antes de fallar, en vez de fallar inmediatamente.
No hay una herramienta de migraciones externa. Para un proyecto de este tamaño, CREATE TABLE IF NOT EXISTS al arrancar es suficiente y no añade una dependencia más.
internal/adapters/sqlite/note_repository.go
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"notesapi/internal/domain"
)
type NoteRepository struct {
db *sql.DB
}
func NewNoteRepository(db *sql.DB) *NoteRepository {
return &NoteRepository{db: db}
}
func (r *NoteRepository) Save(ctx context.Context, note *domain.Note) error {
const query = `
INSERT INTO notes (id, title, content, done, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
content = excluded.content,
done = excluded.done,
updated_at = excluded.updated_at
`
_, err := r.db.ExecContext(ctx, query,
note.ID, note.Title, note.Content, note.Done, note.CreatedAt, note.UpdatedAt,
)
if err != nil {
return fmt.Errorf("save note: %w", err)
}
return nil
}
func (r *NoteRepository) FindByID(ctx context.Context, id string) (*domain.Note, error) {
const query = `SELECT id, title, content, done, created_at, updated_at FROM notes WHERE id = ?`
var n domain.Note
err := r.db.QueryRowContext(ctx, query, id).Scan(
&n.ID, &n.Title, &n.Content, &n.Done, &n.CreatedAt, &n.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("find note: %w", err)
}
return &n, nil
}
func (r *NoteRepository) FindAll(ctx context.Context, filter domain.ListFilter) ([]*domain.Note, error) {
var query strings.Builder
query.WriteString(`SELECT id, title, content, done, created_at, updated_at FROM notes WHERE 1 = 1`)
var args []any
if filter.Query != "" {
query.WriteString(` AND (title LIKE ? OR content LIKE ?)`)
pattern := "%" + filter.Query + "%"
args = append(args, pattern, pattern)
}
if filter.Done != nil {
query.WriteString(` AND done = ?`)
args = append(args, *filter.Done)
}
limit := filter.Limit
if limit <= 0 {
limit = 50
}
query.WriteString(` ORDER BY updated_at DESC LIMIT ? OFFSET ?`)
args = append(args, limit, filter.Offset)
rows, err := r.db.QueryContext(ctx, query.String(), args...)
if err != nil {
return nil, fmt.Errorf("list notes: %w", err)
}
defer rows.Close()
notes := make([]*domain.Note, 0, limit)
for rows.Next() {
var n domain.Note
if err := rows.Scan(&n.ID, &n.Title, &n.Content, &n.Done, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan note: %w", err)
}
notes = append(notes, &n)
}
return notes, rows.Err()
}
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("delete note: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if affected == 0 {
return domain.ErrNotFound
}
return nil
}
Save hace un UPSERT: si el id ya existe, actualiza; si no, inserta. Esto permite usar el mismo método tanto para crear como para actualizar, sin duplicar lógica SQL. SQLite no tiene un tipo BOOLEAN nativo — guarda done como INTEGER (0 o 1), y database/sql hace la conversión a bool de forma transparente en Scan.
*NoteRepository implementa domain.NoteRepository de forma implícita — no hay implements explícito en Go. El compilador lo verifica en el momento en que lo conectas al servicio, en main.go.
Paso 4: El Adaptador HTTP — Gin al Frente
Este adaptador traduce HTTP a llamadas de aplicación, y respuestas de aplicación a JSON. Nada de lógica de negocio aquí.
internal/adapters/httpapi/dto.go
package httpapi
import (
"time"
"notesapi/internal/domain"
)
type createNoteRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
}
type updateNoteRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
}
type setDoneRequest struct {
Done bool `json:"done"`
}
type noteResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toNoteResponse(n *domain.Note) noteResponse {
return noteResponse{
ID: n.ID,
Title: n.Title,
Content: n.Content,
Done: n.Done,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
}
}
Los DTOs viven separados del dominio a propósito. Si mañana cambias el formato de fecha en la API, o agregas un campo calculado en la respuesta, tocas este archivo — no domain.Note.
internal/adapters/httpapi/handler.go
package httpapi
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"notesapi/internal/application"
"notesapi/internal/domain"
)
type NoteHandler struct {
service application.NoteService
}
func NewNoteHandler(service application.NoteService) *NoteHandler {
return &NoteHandler{service: service}
}
func (h *NoteHandler) Create(c *gin.Context) {
var req createNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
note, err := h.service.Create(c.Request.Context(), req.Title, req.Content)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, toNoteResponse(note))
}
func (h *NoteHandler) Get(c *gin.Context) {
note, err := h.service.Get(c.Request.Context(), c.Param("id"))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, toNoteResponse(note))
}
func (h *NoteHandler) List(c *gin.Context) {
filter := domain.ListFilter{
Query: c.Query("q"),
Limit: parseIntDefault(c.Query("limit"), 50),
Offset: parseIntDefault(c.Query("offset"), 0),
}
if raw := c.Query("done"); raw != "" {
done, err := strconv.ParseBool(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "done must be true or false"})
return
}
filter.Done = &done
}
notes, err := h.service.List(c.Request.Context(), filter)
if err != nil {
respondError(c, err)
return
}
responses := make([]noteResponse, 0, len(notes))
for _, n := range notes {
responses = append(responses, toNoteResponse(n))
}
c.JSON(http.StatusOK, responses)
}
func (h *NoteHandler) Update(c *gin.Context) {
var req updateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
note, err := h.service.Update(c.Request.Context(), c.Param("id"), req.Title, req.Content)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, toNoteResponse(note))
}
func (h *NoteHandler) SetDone(c *gin.Context) {
var req setDoneRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
note, err := h.service.SetDone(c.Request.Context(), c.Param("id"), req.Done)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, toNoteResponse(note))
}
func (h *NoteHandler) Delete(c *gin.Context) {
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func respondError(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, domain.ErrEmptyTitle), errors.Is(err, domain.ErrTitleTooLong):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}
func parseIntDefault(raw string, fallback int) int {
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
return fallback
}
return value
}
respondError es el único lugar que traduce errores de dominio a códigos HTTP. errors.Is funciona incluso si el error viene envuelto con fmt.Errorf("...: %w", err) en capas inferiores — por eso todas las capas anteriores usan %w y no %v.
internal/adapters/httpapi/router.go
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
)
func NewRouter(handler *NoteHandler) *gin.Engine {
router := gin.New()
router.Use(gin.Logger(), gin.Recovery())
router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
notes := router.Group("/api/v1/notes")
{
notes.POST("", handler.Create)
notes.GET("", handler.List)
notes.GET("/:id", handler.Get)
notes.PUT("/:id", handler.Update)
notes.PATCH("/:id/done", handler.SetDone)
notes.DELETE("/:id", handler.Delete)
}
return router
}
Fíjate que el paquete se llama httpapi, no http — evita chocar visualmente con el paquete estándar net/http, que se importa en el mismo archivo.
Paso 5: Configuración Sin Dramas
Nada de Viper ni archivos .yaml para tres variables de entorno.
internal/config/config.go
package config
import (
"cmp"
"os"
)
type Config struct {
Port string
DBPath string
GinMode string
}
func Load() Config {
return Config{
Port: cmp.Or(os.Getenv("PORT"), "8080"),
DBPath: cmp.Or(os.Getenv("DB_PATH"), "notes.db"),
GinMode: cmp.Or(os.Getenv("GIN_MODE"), "release"),
}
}
cmp.Or es Go moderno de verdad: toma el primer valor no-cero de una lista. Reemplaza el clásico if x == "" { x = default } repetido tres veces.
Paso 6: main.go — Conectando Todo
Aquí es donde el dominio, la aplicación y los adaptadores se encuentran por primera vez. Es la única capa que conoce a todas las demás.
cmd/api/main.go
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"notesapi/internal/adapters/httpapi"
"notesapi/internal/adapters/sqlite"
"notesapi/internal/application"
"notesapi/internal/config"
)
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
cfg := config.Load()
db, err := sqlite.Open(cfg.DBPath)
if err != nil {
slog.Error("open database", "error", err)
os.Exit(1)
}
defer db.Close()
repo := sqlite.NewNoteRepository(db)
service := application.NewNoteService(repo)
handler := httpapi.NewNoteHandler(service)
gin.SetMode(cfg.GinMode)
router := httpapi.NewRouter(handler)
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
slog.Info("server listening", "port", cfg.Port)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", "error", err)
}
}
Tres cosas modernas que vale la pena notar:
log/slogpara logs estructurados en JSON, sin dependencias externas.signal.NotifyContextconvierte señales del sistema operativo (Ctrl+C,SIGTERM) en uncontext.Contextque se cancela solo — así el servidor apaga limpio, sin dejar conexiones a medias.ReadHeaderTimeouten elhttp.Server— una línea que evita ataques slowloris básicos. Sin esto,go vetincluso te lo señala.
Corre la API:
go run ./cmd/api
Probando la API con curl
Con el servidor arriba en :8080:
curl -X POST localhost:8080/api/v1/notes \
-H "Content-Type: application/json" \
-d '{"title":"Aprender Go 1.27","content":"Repasar slog y cmp.Or"}'
curl localhost:8080/api/v1/notes
curl localhost:8080/api/v1/notes/<id>
curl -X PUT localhost:8080/api/v1/notes/<id> \
-H "Content-Type: application/json" \
-d '{"title":"Aprender Go 1.27 a fondo","content":"Terminado el capítulo de generics"}'
curl -X PATCH localhost:8080/api/v1/notes/<id>/done \
-H "Content-Type: application/json" \
-d '{"done":true}'
curl "localhost:8080/api/v1/notes?q=go&done=true&limit=10"
curl -X DELETE localhost:8080/api/v1/notes/<id> -i
Paso 7: Tests — Confía Pero Verifica
La ventaja completa de separar dominio y adaptadores se demuestra aquí. Dos tipos de test, dos velocidades distintas.
internal/domain/note_test.go — puro, sin base de datos, corre en microsegundos
package domain
import (
"errors"
"strings"
"testing"
)
func TestNewNote(t *testing.T) {
tests := []struct {
name string
title string
content string
wantErr error
}{
{name: "valid note", title: "Comprar café", content: "Antes de las 9am"},
{name: "empty title", title: " ", content: "algo", wantErr: ErrEmptyTitle},
{name: "title too long", title: strings.Repeat("a", 121), wantErr: ErrTitleTooLong},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
note, err := NewNote(tt.title, tt.content)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("NewNote() error = %v, want %v", err, tt.wantErr)
}
if tt.wantErr == nil && note.ID == "" {
t.Fatal("expected generated ID")
}
})
}
}
internal/adapters/httpapi/handler_test.go — integración real, SQLite en memoria, sin mocks
package httpapi_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"notesapi/internal/adapters/httpapi"
"notesapi/internal/adapters/sqlite"
"notesapi/internal/application"
)
func newTestRouter(t *testing.T) *gin.Engine {
t.Helper()
db, err := sqlite.Open(":memory:")
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { db.Close() })
repo := sqlite.NewNoteRepository(db)
service := application.NewNoteService(repo)
handler := httpapi.NewNoteHandler(service)
gin.SetMode(gin.TestMode)
return httpapi.NewRouter(handler)
}
func TestCreateAndGetNote(t *testing.T) {
router := newTestRouter(t)
body, _ := json.Marshal(map[string]string{
"title": "Aprender Go 1.27",
"content": "Repasar generics y slog",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/notes", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var created map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode response: %v", err)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/notes/"+created["id"].(string), nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", getRec.Code, getRec.Body.String())
}
}
Este segundo test usa sqlite.Open(":memory:") — la misma función de producción, apuntando a una base de datos en memoria. No hay mocks del repositorio: el router completo, con el handler real y el repositorio real, responde a una petición HTTP real. Es rápido porque SQLite en memoria es rápido, y es honesto porque prueba el sistema tal como se comporta en producción.
Corre todo:
go test ./...
Paso 8: Docker — Levantar Todo con Un Comando
Como modernc.org/sqlite no usa CGO, el Dockerfile no necesita gcc ni musl-dev en ninguna etapa. Eso simplifica todo.
Dockerfile
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/notesapi ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/notesapi /notesapi
EXPOSE 8080
VOLUME ["/data"]
ENV DB_PATH=/data/notes.db
ENV GIN_MODE=release
ENTRYPOINT ["/notesapi"]
La imagen final es distroless/static: sin shell, sin gestor de paquetes, sin nada que un atacante pueda usar si compromete el proceso. Solo el binario estático.
docker-compose.yml
services:
notesapi:
build: .
ports:
- "8080:8080"
volumes:
- notes-data:/data
environment:
PORT: "8080"
DB_PATH: "/data/notes.db"
GIN_MODE: "release"
restart: unless-stopped
volumes:
notes-data:
Makefile — atajos, no obligatorios pero cómodos
run:
go run ./cmd/api
test:
go test ./...
build:
go build -o bin/notesapi ./cmd/api
up:
docker compose up --build
down:
docker compose down
Levanta todo:
docker compose up --build
La base de datos vive en un volumen nombrado (notes-data), así que sobrevive a docker compose down y solo desaparece si borras el volumen explícitamente.
Estructura Final del Proyecto
notesapi/
├── cmd/api/main.go
├── internal/
│ ├── domain/{note,errors,repository,note_test}.go
│ ├── application/{ports,note_service}.go
│ ├── adapters/
│ │ ├── httpapi/{dto,handler,handler_test,router}.go
│ │ └── sqlite/{db,note_repository}.go
│ └── config/config.go
├── go.mod
├── go.sum
├── Dockerfile
├── docker-compose.yml
└── Makefile
Cada carpeta responde a una pregunta distinta: domain es “¿qué es una nota?”, application es “¿qué se puede hacer con notas?”, adapters/httpapi es “¿cómo entra el mundo exterior?”, adapters/sqlite es “¿cómo se guarda?”.
Qué Ganaste (Y Qué Sigue)
Construiste una API completa donde ninguna capa sabe más de lo que necesita. El dominio no sabe que existe Gin. La aplicación no sabe que existe SQLite. El test de dominio corre sin base de datos; el test HTTP corre sin mocks. Y todo el proyecto usa exactamente dos dependencias externas.
De aquí, los siguientes pasos naturales son tuyos para practicar:
- Cambia el filtro por texto (
LIKE) por búsqueda de texto completo conFTS5de SQLite. - Agrega autenticación con un middleware de Gin que valide un JWT antes de llegar al handler — sin tocar el dominio ni la aplicación.
- Sustituye
sqliteporpostgresimplementando un segundo adaptador que cumpla la misma interfazdomain.NoteRepository. Si el ejercicio anterior está bien hecho, es el único archivo nuevo que necesitas escribir.
Esa última prueba — cambiar de base de datos tocando un solo archivo — es la métrica real de si entendiste la arquitectura hexagonal, o solo copiaste carpetas con nombres bonitos.
Artículos relacionados
Por relevancia
Construir una API de Notas en Go: Chi, SQLite, DDD y Arquitectura Hexagonal Sin Magia
Aprende a construir una API REST de notas lista para producción en Go usando Chi, SQLite y Arquitectura Hexagonal. Sin frameworks que oculten complejidad, solo dominios claros y dependencias explícitas.
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 Profesional de Desarrollo: API de Notas en Go con Uber Fx, Gin, GORM y Stack de Industria
Segunda parte del flujo de construcción de la API de notas: migramos de la stdlib pura a un stack de industria con Uber Fx (inyección de dependencias), Gin, GORM, UUID, Zap, Viper y Air. Arquitectura Hexagonal y DDD intactas, wiring declarativo y graceful shutdown vía fx.Lifecycle.
Go 1.25 API Médica: DDD, TDD, BDD, PostgreSQL, Redis — Sistema Clínico Completo
Construye una API de registros médicos lista para producción en Go 1.25 con DDD, TDD, BDD, escenarios Gherkin, PostgreSQL, Redis. Historial clínico, tableros y cada archivo explicado.