Construir una API de Notas en Go: Chi, SQLite, DDD y Arquitectura Hexagonal Sin Magia

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.

Por Omar Flores

La Trampa: Elegir Velocidad Sobre Comprensión

Imagina que necesitas construir una aplicación de notas. Buscas “Go REST API tutorial” y encuentras un framework que maneja todo. Autenticación, validación, consultas de base de datos, enrutamiento HTTP, serialización. Todo envuelto en decoradores y middleware. Sigues el tutorial. En dos horas, tienes una API funcionando.

Funciona. Pero no entiendes cómo funciona.

Seis meses después, necesitas cambiar la base de datos de PostgreSQL a SQLite. Descubres que tu lógica de negocio está enmarañada con tus consultas de base de datos. Necesitas cambiar de SQLite a S3. Mismo problema. Necesitas escribir pruebas unitarias que no toquen la base de datos. Te das cuenta de que tu lógica de dominio importa tu manejador HTTP. No puedes probarlo en aislamiento.

En ese momento, comprendes: la velocidad al inicio te cuesta durante años después.

Existe un mejor enfoque. Toma ligeramente más tiempo de escribir. Pero todo es explícito. Todo es testeable. Todo es tuyo — sin magia escondiendo costos. Esta guía te muestra cómo construir una API de notas real de esa manera.


Qué Estamos Construyendo

Una aplicación de notas simple pero realista con estas características:

  • Crear, leer, actualizar, eliminar notas
  • Listar notas con filtrado
  • Buscar notas por título o contenido
  • Persistencia en SQLite
  • API RESTful servida por Chi

Pero más importante, estamos construyéndola con claridad. La lógica de negocio es separada de la base de datos. El manejador HTTP no conoce SQL. El dominio no sabe nada de HTTP. Todo es explícito. Sin magia de framework.


Parte 1: La Capa de Dominio — Donde Vive la Lógica de Negocio

La capa de dominio contiene los conceptos centrales de tu aplicación, escritos en el lenguaje del negocio. No el lenguaje de bases de datos o HTTP. El lenguaje de notas.

Definiendo la Nota

En tu aplicación de notas, el concepto central es una Nota. ¿Qué es una Nota en el lenguaje de tu negocio?

Una Nota es una idea que vale la pena mantener. Tiene un ID (para encontrarlo después), un título (para identificarlo rápidamente), contenido (la idea actual), y timestamps (para saber cuándo fue creada y cambida).

Aquí es lo que se ve en código — nota que no hay etiqueta sql, no hay manejador HTTP, no hay lógica de serialización. Solo el dominio:

// Capa de dominio: domain/note.go
package domain

import "time"

// Note representa una idea que un usuario quiere mantener.
// Todas las reglas de negocio sobre una nota viven aquí.
type Note struct {
	ID        string
	Title     string
	Content   string
	CreatedAt time.Time
	UpdatedAt time.Time
}

// NewNote crea una nota con validación.
// El dominio aplica sus propias reglas.
func NewNote(title, content string) (*Note, error) {
	if len(title) == 0 {
		return nil, ErrTitleRequired
	}
	if len(content) == 0 {
		return nil, ErrContentRequired
	}

	return &Note{
		Title:     title,
		Content:   content,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}, nil
}

// Update cambia el contenido de la nota y el timestamp.
func (n *Note) Update(title, content string) error {
	if len(title) == 0 {
		return ErrTitleRequired
	}
	if len(content) == 0 {
		return ErrContentRequired
	}

	n.Title = title
	n.Content = content
	n.UpdatedAt = time.Now()
	return nil
}

Nota lo que pasó. El tipo Note tiene lógica de validación. Tiene métodos que aplican la regla de negocio — una nota siempre tiene un título y contenido, y cuando se actualiza, el timestamp cambia. El dominio posee sus propias reglas.

Esto es diferente de un struct con campos. Esto es un tipo que sabe qué significa ser una Nota.


Definiendo la Interfaz del Repositorio

Ahora necesitas persistir notas. En el dominio, no te importa cómo. SQLite hoy, PostgreSQL mañana, quizás incluso un almacén en memoria para pruebas. Define una interfaz — un contrato — que diga “quien persista notas debe poder hacer estas cosas”:

// Capa de dominio: domain/repository.go
package domain

import "context"

// NoteRepository define el contrato para persistir y recuperar notas.
// El dominio define qué necesita. La infraestructura lo implementa.
type NoteRepository interface {
	// Save almacena una nota. Si existe, la actualiza. Si no, la crea.
	Save(ctx context.Context, note *Note) error

	// FindByID recupera una nota por su ID.
	FindByID(ctx context.Context, id string) (*Note, error)

	// FindAll recupera todas las notas.
	FindAll(ctx context.Context) ([]*Note, error)

	// Delete elimina una nota.
	Delete(ctx context.Context, id string) error

	// Search encuentra notas cuyo título o contenido contiene la consulta.
	Search(ctx context.Context, query string) ([]*Note, error)
}

Esta interfaz vive en la capa de dominio. Habla el lenguaje de notas, no de bases de datos. La capa de infraestructura la implementará usando SQLite. La capa HTTP nunca la tocará directamente. El dominio — la lógica de negocio — habla a través de esta interfaz.


Parte 2: La Capa de Aplicación — Orquestando el Dominio

La capa de aplicación se sienta entre el manejador HTTP y el dominio. Orquesta operaciones de negocio. Llama métodos de dominio, usa repositorios, y retorna resultados a la capa HTTP. No es lógica de negocio. Es coreografía.

// Capa de aplicación: app/create_note.go
package app

import (
	"context"
	"github.com/yourname/notes/domain"
)

// CreateNoteCommand representa la solicitud de crear una nota.
type CreateNoteCommand struct {
	Title   string
	Content string
}

// CreateNoteHandler orquesta la creación de notas.
type CreateNoteHandler struct {
	repo domain.NoteRepository
}

// NewCreateNoteHandler construye el manejador.
func NewCreateNoteHandler(repo domain.NoteRepository) *CreateNoteHandler {
	return &CreateNoteHandler{repo: repo}
}

// Handle ejecuta el comando.
func (h *CreateNoteHandler) Handle(ctx context.Context, cmd CreateNoteCommand) (*domain.Note, error) {
	// El dominio crea y valida la nota.
	note, err := domain.NewNote(cmd.Title, cmd.Content)
	if err != nil {
		return nil, err // Validación del dominio falló.
	}

	// Genera un ID (usualmente lo haces antes de guardar, o deja que la BD lo genere).
	note.ID = domain.GenerateID()

	// Persiste a través del repositorio.
	if err := h.repo.Save(ctx, note); err != nil {
		return nil, err
	}

	return note, nil
}

Nota lo que falta. El manejador no toca HTTP. No sabe de bases de datos. Recibe un comando (la intención del usuario), orquesta el dominio, y retorna el resultado. Separación limpia.


Parte 3: La Capa de Infraestructura — Haciéndolo Real

Ahora implementamos el repositorio usando SQLite. Aquí es donde vive SQL. Solo aquí.

// Capa de infraestructura: infra/sqlite_note_repository.go
package infra

import (
	"context"
	"database/sql"
	"fmt"

	_ "github.com/mattn/go-sqlite3"
	"github.com/yourname/notes/domain"
)

type SQLiteNoteRepository struct {
	db *sql.DB
}

func NewSQLiteNoteRepository(db *sql.DB) *SQLiteNoteRepository {
	return &SQLiteNoteRepository{db: db}
}

func (r *SQLiteNoteRepository) Save(ctx context.Context, note *domain.Note) error {
	const query = `
		INSERT INTO notes (id, title, content, created_at, updated_at)
		VALUES (?, ?, ?, ?, ?)
		ON CONFLICT(id) DO UPDATE SET title = ?, content = ?, updated_at = ?
	`

	_, err := r.db.ExecContext(
		ctx, query,
		note.ID, note.Title, note.Content, note.CreatedAt, note.UpdatedAt,
		note.Title, note.Content, note.UpdatedAt,
	)
	return err
}

func (r *SQLiteNoteRepository) FindByID(ctx context.Context, id string) (*domain.Note, error) {
	const query = `SELECT id, title, content, created_at, updated_at FROM notes WHERE id = ?`

	var note domain.Note
	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&note.ID, &note.Title, &note.Content, &note.CreatedAt, &note.UpdatedAt,
	)
	if err == sql.ErrNoRows {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, err
	}

	return &note, nil
}

func (r *SQLiteNoteRepository) FindAll(ctx context.Context) ([]*domain.Note, error) {
	const query = `SELECT id, title, content, created_at, updated_at FROM notes ORDER BY updated_at DESC`

	rows, err := r.db.QueryContext(ctx, query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var notes []*domain.Note
	for rows.Next() {
		var note domain.Note
		if err := rows.Scan(&note.ID, &note.Title, &note.Content, &note.CreatedAt, &note.UpdatedAt); err != nil {
			return nil, err
		}
		notes = append(notes, &note)
	}

	return notes, rows.Err()
}

func (r *SQLiteNoteRepository) Delete(ctx context.Context, id string) error {
	const query = `DELETE FROM notes WHERE id = ?`
	_, err := r.db.ExecContext(ctx, query, id)
	return err
}

func (r *SQLiteNoteRepository) Search(ctx context.Context, query string) ([]*domain.Note, error) {
	const sql = `
		SELECT id, title, content, created_at, updated_at
		FROM notes
		WHERE title LIKE ? OR content LIKE ?
		ORDER BY updated_at DESC
	`

	pattern := "%" + query + "%"
	rows, err := r.db.QueryContext(ctx, sql, pattern, pattern)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var notes []*domain.Note
	for rows.Next() {
		var note domain.Note
		if err := rows.Scan(&note.ID, &note.Title, &note.Content, &note.CreatedAt, &note.UpdatedAt); err != nil {
			return nil, err
		}
		notes = append(notes, &note)
	}

	return notes, rows.Err()
}

Esto es SQL directo con database/sql — la librería estándar. Sin ORM. Sin magia. Cada consulta es visible. Puedes leerla y entender exactamente qué está pasando. El repositorio implementa la interfaz del dominio, así que el dominio nunca sabe que este código existe. Cambiar SQLite por PostgreSQL significa reescribir este archivo. Todo lo demás se queda igual.


Parte 4: La Capa HTTP — Exponiendo la API

Chi es un enrutador HTTP ligero. No oculta lógica. Solo enruta solicitudes y sirve respuestas.

// Capa HTTP: http/handler.go
package http

import (
	"encoding/json"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/yourname/notes/app"
	"github.com/yourname/notes/domain"
)

// NoteResponse es lo que la API retorna.
type NoteResponse struct {
	ID        string `json:"id"`
	Title     string `json:"title"`
	Content   string `json:"content"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
}

// Handler agrupa todas las operaciones de notas.
type Handler struct {
	createHandler *app.CreateNoteHandler
	getHandler    *app.GetNoteHandler
	listHandler   *app.ListNotesHandler
	updateHandler *app.UpdateNoteHandler
	deleteHandler *app.DeleteNoteHandler
	searchHandler *app.SearchNotesHandler
}

// NewHandler construye el manejador.
func NewHandler(
	createHandler *app.CreateNoteHandler,
	getHandler *app.GetNoteHandler,
	listHandler *app.ListNotesHandler,
	updateHandler *app.UpdateNoteHandler,
	deleteHandler *app.DeleteNoteHandler,
	searchHandler *app.SearchNotesHandler,
) *Handler {
	return &Handler{
		createHandler: createHandler,
		getHandler:    getHandler,
		listHandler:   listHandler,
		updateHandler: updateHandler,
		deleteHandler: deleteHandler,
		searchHandler: searchHandler,
	}
}

// RegisterRoutes registra todas las rutas con Chi.
func (h *Handler) RegisterRoutes(r chi.Router) {
	r.Post("/notes", h.CreateNote)
	r.Get("/notes/{id}", h.GetNote)
	r.Get("/notes", h.ListNotes)
	r.Put("/notes/{id}", h.UpdateNote)
	r.Delete("/notes/{id}", h.DeleteNote)
	r.Get("/notes/search", h.SearchNotes)
}

// CreateNote maneja POST /notes.
func (h *Handler) CreateNote(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Title   string `json:"title"`
		Content string `json:"content"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}

	cmd := app.CreateNoteCommand{
		Title:   req.Title,
		Content: req.Content,
	}

	note, err := h.createHandler.Handle(r.Context(), cmd)
	if err == domain.ErrTitleRequired || err == domain.ErrContentRequired {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(noteToResponse(note))
}

// GetNote maneja GET /notes/{id}.
func (h *Handler) GetNote(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")

	note, err := h.getHandler.Handle(r.Context(), id)
	if err == domain.ErrNotFound {
		http.Error(w, "note not found", http.StatusNotFound)
		return
	}
	if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(noteToResponse(note))
}

// ListNotes maneja GET /notes.
func (h *Handler) ListNotes(w http.ResponseWriter, r *http.Request) {
	notes, err := h.listHandler.Handle(r.Context())
	if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(notesToResponses(notes))
}

// SearchNotes maneja GET /notes/search?q=query.
func (h *Handler) SearchNotes(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query().Get("q")
	if query == "" {
		http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
		return
	}

	notes, err := h.searchHandler.Handle(r.Context(), query)
	if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(notesToResponses(notes))
}

// UpdateNote maneja PUT /notes/{id}.
func (h *Handler) UpdateNote(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")

	var req struct {
		Title   string `json:"title"`
		Content string `json:"content"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}

	cmd := app.UpdateNoteCommand{
		ID:      id,
		Title:   req.Title,
		Content: req.Content,
	}

	note, err := h.updateHandler.Handle(r.Context(), cmd)
	if err == domain.ErrNotFound {
		http.Error(w, "note not found", http.StatusNotFound)
		return
	}
	if err == domain.ErrTitleRequired || err == domain.ErrContentRequired {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(noteToResponse(note))
}

// DeleteNote maneja DELETE /notes/{id}.
func (h *Handler) DeleteNote(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")

	if err := h.deleteHandler.Handle(r.Context(), id); err == domain.ErrNotFound {
		http.Error(w, "note not found", http.StatusNotFound)
		return
	} else if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

func noteToResponse(note *domain.Note) NoteResponse {
	return NoteResponse{
		ID:        note.ID,
		Title:     note.Title,
		Content:   note.Content,
		CreatedAt: note.CreatedAt.Format("2006-01-02T15:04:05Z"),
		UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

func notesToResponses(notes []*domain.Note) []NoteResponse {
	resp := make([]NoteResponse, len(notes))
	for i, note := range notes {
		resp[i] = noteToResponse(note)
	}
	return resp
}

El manejador HTTP no sabe nada sobre la base de datos. No importa la implementación SQLite. Recibe manejadores de aplicación como dependencias — todo inyectado por la raíz de composición. Cuando viene una solicitud, analiza la entrada, llama al manejador de aplicación, traduce errores a códigos de estado HTTP, y retorna JSON.


Parte 5: Composición — Conectando Todo

La función main conecta todas estas capas. Aquí es donde las dependencias fluyen hacia adentro.

// main.go
package main

import (
	"database/sql"
	"log"
	"net/http"

	_ "github.com/mattn/go-sqlite3"
	"github.com/go-chi/chi/v5"
	"github.com/yourname/notes/app"
	"github.com/yourname/notes/infra"
	httphandlers "github.com/yourname/notes/http"
)

func main() {
	// 1. Configura la base de datos.
	db, err := sql.Open("sqlite3", ":memory:") // o "notes.db" para almacenamiento persistente
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Crea la tabla.
	if err := createSchema(db); err != nil {
		log.Fatal(err)
	}

	// 2. Crea el repositorio (capa de infraestructura).
	noteRepo := infra.NewSQLiteNoteRepository(db)

	// 3. Crea manejadores de aplicación.
	createHandler := app.NewCreateNoteHandler(noteRepo)
	getHandler := app.NewGetNoteHandler(noteRepo)
	listHandler := app.NewListNotesHandler(noteRepo)
	updateHandler := app.NewUpdateNoteHandler(noteRepo)
	deleteHandler := app.NewDeleteNoteHandler(noteRepo)
	searchHandler := app.NewSearchNotesHandler(noteRepo)

	// 4. Crea el manejador HTTP.
	handler := httphandlers.NewHandler(
		createHandler,
		getHandler,
		listHandler,
		updateHandler,
		deleteHandler,
		searchHandler,
	)

	// 5. Configura el enrutador.
	router := chi.NewRouter()
	handler.RegisterRoutes(router)

	// 6. Inicia el servidor.
	log.Println("Server running on :8080")
	if err := http.ListenAndServe(":8080", router); err != nil {
		log.Fatal(err)
	}
}

func createSchema(db *sql.DB) error {
	const schema = `
		CREATE TABLE IF NOT EXISTS notes (
			id TEXT PRIMARY KEY,
			title TEXT NOT NULL,
			content TEXT NOT NULL,
			created_at TIMESTAMP NOT NULL,
			updated_at TIMESTAMP NOT NULL
		);

		CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes(updated_at DESC);
		CREATE INDEX IF NOT EXISTS idx_notes_search ON notes(title, content);
	`
	_, err := db.Exec(schema)
	return err
}

Nota lo que sucedió. La base de datos se crea. El repositorio se crea. Los manejadores de aplicación se crean e inyectan con el repositorio. El manejador HTTP se crea con los manejadores de aplicación. Todo fluye hacia adentro. Las capas exteriores dependen de las capas interiores, nunca al revés.


Parte 6: Pruebas — Por Qué Esta Arquitectura Importa

Ahora entiendes por qué todo esto importa. Las pruebas son simples.

// app/create_note_test.go
package app

import (
	"context"
	"testing"

	"github.com/yourname/notes/domain"
)

// MockRepository es una implementación falsa para pruebas.
type MockRepository struct {
	saved map[string]*domain.Note
}

func NewMockRepository() *MockRepository {
	return &MockRepository{saved: make(map[string]*domain.Note)}
}

func (m *MockRepository) Save(ctx context.Context, note *domain.Note) error {
	m.saved[note.ID] = note
	return nil
}

func (m *MockRepository) FindByID(ctx context.Context, id string) (*Note, error) {
	if note, ok := m.saved[id]; ok {
		return note, nil
	}
	return nil, domain.ErrNotFound
}

func (m *MockRepository) FindAll(ctx context.Context) ([]*domain.Note, error) {
	notes := make([]*domain.Note, 0, len(m.saved))
	for _, note := range m.saved {
		notes = append(notes, note)
	}
	return notes, nil
}

func (m *MockRepository) Delete(ctx context.Context, id string) error {
	delete(m.saved, id)
	return nil
}

func (m *MockRepository) Search(ctx context.Context, query string) ([]*domain.Note, error) {
	return nil, nil // Simplificado para este ejemplo
}

// Test que el manejador crea una nota con los datos correctos.
func TestCreateNoteHandler(t *testing.T) {
	repo := NewMockRepository()
	handler := NewCreateNoteHandler(repo)

	cmd := CreateNoteCommand{
		Title:   "Test Note",
		Content: "This is a test",
	}

	note, err := handler.Handle(context.Background(), cmd)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}

	if note.Title != cmd.Title || note.Content != cmd.Content {
		t.Fatal("note data mismatch")
	}

	// Verifica que la nota fue guardada.
	saved, err := repo.FindByID(context.Background(), note.ID)
	if err != nil {
		t.Fatalf("expected note to be saved, got error: %v", err)
	}

	if saved.ID != note.ID {
		t.Fatal("saved note ID mismatch")
	}
}

No necesitas una base de datos para pruebas. Inyectas un mock. Pruebas la lógica de aplicación en aislamiento. Es rápido. Es confiable. No depende de estado externo.


Por Qué Este Enfoque — Los Trade-Offs

Sin magia significa que todo es explícito. Puedes leer el código y entender qué hace. Cuando algo se rompe, sabes dónde buscar.

Testeable significa que puedes escribir pruebas sin mockear todo. La capa de dominio no tiene dependencias externas. La capa de aplicación se prueba con un repositorio mock. El repositorio se prueba separadamente con una base de datos real o una conexión mock.

Flexible significa que puedes cambiar la base de datos sin cambiar las capas de dominio o aplicación. Puedes cambiar el framework HTTP sin cambiar el dominio. Tu lógica de negocio es realmente separada.

El trade-off es más código. Un framework podría darte la misma API en la mitad de archivos. Pero esos archivos serían más complejos, más difíciles de probar, y más difíciles de cambiar. Elegiste claridad y flexibilidad sobre brevedad.


Construyéndolo: Los Pasos Prácticos

  1. Crea el dominio: el tipo Note y la interfaz NoteRepository.
  2. Crea los manejadores de aplicación: CreateNote, GetNote, ListNotes, UpdateNote, DeleteNote, SearchNotes.
  3. Crea el repositorio SQLite implementando NoteRepository.
  4. Crea los manejadores HTTP en Chi.
  5. Conecta todo en main().
  6. Prueba cada capa independientemente.

Eso es una API REST completa. Sin magia de framework. Sin dependencias ocultas. Todo explícito. Cambiable. Testeable.


Cuándo Usar Este Enfoque

Usa esta arquitectura cuando:

  • Esperas que el proyecto viva por años, no semanas.
  • Necesitarás cambiar la base de datos u otra infraestructura.
  • Tu lógica de dominio es lo suficientemente compleja para merecer protección.
  • Necesitas escribir pruebas que no dependan de sistemas externos.
  • Tu equipo valora la claridad sobre la brevedad.

No uses esta arquitectura cuando:

  • Estás prototipando y la velocidad es el único objetivo.
  • La API es tan simple que la capa de dominio no añade valor.
  • Estás aprendiendo Go por primera vez y necesitas mantenerte enfocado.

Para una aplicación de notas real que evolucionará, que escalará, que será mantenida por años. Esto es exactamente correcto.

El código más claro no es el código más corto. Es el código que te muestra qué está realmente sucediendo. Chi y SQLite no ocultan nada. La Arquitectura Hexagonal te muestra dónde pertenece todo. Esa claridad vale la pena de los archivos adicionales.