Full Stack con Go: App de Notas y Tareas con HTMX, Templ y SQLite
Construye una app completa de notas y todos en Go: notas markdown con tags, listas de tareas, búsqueda full-text, autenticación, HTMX y Templ.
Full Stack con Go: App de Notas y Tareas con HTMX, Templ y SQLite
Piensa en un cuaderno bien organizado. La portada, las páginas, las pestañas y el marcador son todos parte del mismo objeto — algo que cargas, abres y guardas. Una aplicación Go full stack tiene esa misma cualidad: un binario lleva el router, los templates, las reglas de negocio y la conexión a la base de datos. Abres un archivo para entender el sistema. Despliegas un artefacto.
Esta guía construye Noteflow — una aplicación de notas y tareas donde los usuarios pueden escribir notas en markdown con tags y fijado, gestionar listas de tareas con prioridades y fechas límite, buscar en todo de forma instantánea, y hacer todo esto sin recargar la página. El stack completo es Go: modernc.org/sqlite (sin CGO), Templ para templates type-safe, HTMX para reactividad dirigida por el servidor, Alpine.js para estado local de UI, y Tailwind CSS para los estilos.
Por Qué SQLite para Este Proyecto
PostgreSQL es la elección correcta para despliegues multi-servidor y alta concurrencia de escritura. SQLite es la elección correcta para todo lo demás: aplicaciones de servidor único, herramientas embebidas, entornos de desarrollo, y aplicaciones donde la simplicidad operacional importa más que el escalado horizontal.
modernc.org/sqlite es un puerto Go puro de SQLite — sin compilador C, sin CGO, sin dependencias de build. La base de datos es un solo archivo. El respaldo es cp noteflow.db noteflow.db.bak. Las migraciones son archivos SQL planos. La aplicación completa se despliega como un binario más un archivo de base de datos.
Para una app de notas que sirve a un usuario o un equipo pequeño, SQLite maneja cientos de escrituras por segundo y millones de lecturas. Es la herramienta correcta.
El Stack
| Capa | Tecnología | Por qué |
|---|---|---|
| Router | Chi | Middleware composable, compatible con http.Handler |
| Templates | Templ | Compilados, type-safe, con soporte de IDE |
| Reactividad | HTMX | Fragmentos HTML, sin serialización JSON |
| Estado cliente | Alpine.js | Dropdowns, input de tags, toggles locales |
| Estilos | Tailwind CSS | Utility-first, modo oscuro integrado |
| Base de datos | SQLite + modernc.org/sqlite | Cero dependencias, base de datos en un archivo |
| Auth | bcrypt + cookies seguras | Basada en sesiones, simple y correcta |
| Markdown | github.com/yuin/goldmark | Renderizado HTML seguro desde contenido del usuario |
| Búsqueda | SQLite FTS5 | Búsqueda full-text sin Elasticsearch |
Estructura del Proyecto
noteflow/
cmd/
server/
main.go
internal/
config/
config.go
domain/
note.go # Entidades Nota, Tag
todo.go # Entidades TodoList, TodoItem
user.go # Entidades User, Session
errors.go
auth/
repository.go
service.go
middleware.go
note/
repository.go # CRUD de notas + búsqueda FTS
service.go # Renderizado markdown, gestión de tags
todo/
repository.go # CRUD de todos + items
service.go
handler/
auth.go
note.go
todo.go
search.go
view/
layout.templ # Layout base, nav, modo oscuro
auth.templ # Login, registro
note/
list.templ # Grid de notas con sección de fijadas
detail.templ # Vista de nota individual (markdown renderizado)
form.templ # Formulario crear/editar nota
card.templ # Componente tarjeta de nota
todo/
list.templ # Listas de tareas con barras de progreso
detail.templ # Lista individual con checklist
form.templ # Formulario crear/editar lista
item.templ # Item individual con checkbox (parcial HTMX)
search/
results.templ # Resultados de búsqueda unificados
components/
tag.templ # Badge de tag, input de tags
empty.templ # Estados vacíos
toast.templ # Notificaciones toast
migrations/
001_users.up.sql
001_users.down.sql
002_notes.up.sql
002_notes.down.sql
003_todos.up.sql
003_todos.down.sql
static/
css/output.css
js/htmx.min.js
js/alpine.min.js
Makefile
Dockerfile
.air.toml
go.mod
Cada subdirectorio en internal/view/ refleja un concepto de dominio. Un desarrollador que busca el template de la lista de notas abre internal/view/note/list.templ — sin buscar en un directorio templates/ plano.
Capa de Dominio
El dominio describe el problema, no la implementación. Las notas tienen contenido, tags y estado de fijado. Los todos tienen una lista de items, cada uno con su propio estado de completado.
// internal/domain/note.go
package domain
import (
"time"
"github.com/google/uuid"
)
type Note struct {
ID uuid.UUID
UserID uuid.UUID
Title string
Content string // Fuente Markdown
HTML string // HTML renderizado y saneado (no persistido)
Tags []Tag
Pinned bool
WordCount int
CreatedAt time.Time
UpdatedAt time.Time
}
type Tag struct {
ID uuid.UUID
Name string
Color string // Clase de color Tailwind, ej. "blue", "green"
Count int // Cuántas notas usan este tag (para la barra lateral)
}
func (n *Note) HasTag(tagName string) bool {
for _, t := range n.Tags {
if t.Name == tagName {
return true
}
}
return false
}
func (n *Note) Excerpt(chars int) string {
if len(n.Content) <= chars {
return n.Content
}
return n.Content[:chars] + "..."
}
// internal/domain/todo.go
package domain
import (
"time"
"github.com/google/uuid"
)
type TodoList struct {
ID uuid.UUID
UserID uuid.UUID
Title string
Description string
Items []TodoItem
Priority ListPriority
DueDate *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type TodoItem struct {
ID uuid.UUID
ListID uuid.UUID
Text string
Done bool
Position int
CompletedAt *time.Time
CreatedAt time.Time
}
type ListPriority int
const (
ListPriorityNone ListPriority = 0
ListPriorityLow ListPriority = 1
ListPriorityMedium ListPriority = 2
ListPriorityHigh ListPriority = 3
)
func (p ListPriority) Label() string {
switch p {
case ListPriorityLow:
return "Baja"
case ListPriorityMedium:
return "Media"
case ListPriorityHigh:
return "Alta"
default:
return "Sin prioridad"
}
}
func (p ListPriority) BadgeClass() string {
switch p {
case ListPriorityLow:
return "bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300"
case ListPriorityMedium:
return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
case ListPriorityHigh:
return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300"
default:
return "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
}
}
func (l *TodoList) Progress() int {
if len(l.Items) == 0 {
return 0
}
done := 0
for _, item := range l.Items {
if item.Done {
done++
}
}
return (done * 100) / len(l.Items)
}
func (l *TodoList) IsOverdue() bool {
if l.DueDate == nil {
return false
}
return time.Now().After(*l.DueDate) && l.Progress() < 100
}
func (l *TodoList) DoneCount() int {
count := 0
for _, item := range l.Items {
if item.Done {
count++
}
}
return count
}
// internal/domain/errors.go
package domain
import "errors"
var (
ErrNotFound = errors.New("no encontrado")
ErrUnauthorized = errors.New("no autorizado")
ErrDuplicateEmail = errors.New("correo ya registrado")
ErrInvalidInput = errors.New("entrada inválida")
)
Base de Datos: Migraciones SQLite
Tres archivos de migración configuran el esquema. La extensión FTS5 de SQLite impulsa la búsqueda full-text sin ningún motor de búsqueda externo.
-- migrations/001_users.up.sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- migrations/002_notes.up.sql
CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
word_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'blue',
UNIQUE(user_id, name)
);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
-- Índice de búsqueda full-text
CREATE VIRTUAL TABLE notes_fts USING fts5(
title,
content,
content=notes,
content_rowid=rowid
);
-- Mantener FTS sincronizado
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;
CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
INSERT INTO notes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;
CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
END;
CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_updated ON notes(updated_at DESC);
CREATE INDEX idx_note_tags_note ON note_tags(note_id);
CREATE INDEX idx_note_tags_tag ON note_tags(tag_id);
-- migrations/003_todos.up.sql
CREATE TABLE todo_lists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
due_date DATETIME,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE todo_items (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES todo_lists(id) ON DELETE CASCADE,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE VIRTUAL TABLE todos_fts USING fts5(
title,
description,
content=todo_lists,
content_rowid=rowid
);
CREATE TRIGGER todos_fts_insert AFTER INSERT ON todo_lists BEGIN
INSERT INTO todos_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER todos_fts_update AFTER UPDATE ON todo_lists BEGIN
INSERT INTO todos_fts(todos_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
INSERT INTO todos_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER todos_fts_delete AFTER DELETE ON todo_lists BEGIN
INSERT INTO todos_fts(todos_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
END;
CREATE INDEX idx_todo_lists_user ON todo_lists(user_id);
CREATE INDEX idx_todo_items_list ON todo_items(list_id, position);
Los triggers FTS5 mantienen el índice de búsqueda sincronizado automáticamente. Cada insert, update y delete en notes o todo_lists actualiza la tabla virtual. Sin jobs en segundo plano, sin consistencia eventual — el índice de búsqueda siempre está actualizado.
Configuración de SQLite con modernc.org/sqlite
// internal/db/db.go
package db
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("abrir sqlite: %w", err)
}
pragmas := []string{
"PRAGMA journal_mode=WAL", // Write-Ahead Logging para concurrencia
"PRAGMA synchronous=NORMAL", // Fsync solo en checkpoints
"PRAGMA foreign_keys=ON", // Forzar restricciones FK
"PRAGMA cache_size=-64000", // Caché de 64MB
"PRAGMA temp_store=MEMORY", // Tablas temporales en memoria
"PRAGMA busy_timeout=5000", // Esperar 5s antes de SQLITE_BUSY
}
for _, pragma := range pragmas {
if _, err := db.Exec(pragma); err != nil {
return nil, fmt.Errorf("configurar pragma %s: %w", pragma, err)
}
}
db.SetMaxOpenConns(1) // Una sola escritura previene SQLITE_BUSY
db.SetMaxIdleConns(1)
return db, nil
}
Los PRAGMAs importan. El modo WAL permite lecturas concurrentes mientras ocurre una escritura — crítico para una aplicación web. busy_timeout=5000 hace que el driver espere hasta 5 segundos por un bloqueo en vez de retornar inmediatamente SQLITE_BUSY. SetMaxOpenConns(1) previene conflictos de escritura a nivel de la aplicación.
Repositorio de Notas
// internal/note/repository.go
package note
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"noteflow/internal/domain"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(ctx context.Context, note *domain.Note) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("iniciar tx: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO notes (id, user_id, title, content, pinned, word_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
note.ID.String(), note.UserID.String(), note.Title, note.Content,
note.Pinned, note.WordCount, note.CreatedAt, note.UpdatedAt,
)
if err != nil {
return fmt.Errorf("insertar nota: %w", err)
}
if err := r.syncTags(ctx, tx, note.ID, note.UserID, note.Tags); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) Update(ctx context.Context, note *domain.Note) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("iniciar tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE notes SET title=?, content=?, pinned=?, word_count=?, updated_at=?
WHERE id=? AND user_id=?`,
note.Title, note.Content, note.Pinned, note.WordCount, time.Now(),
note.ID.String(), note.UserID.String(),
)
if err != nil {
return fmt.Errorf("actualizar nota: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return domain.ErrNotFound
}
_, err = tx.ExecContext(ctx, `DELETE FROM note_tags WHERE note_id=?`, note.ID.String())
if err != nil {
return fmt.Errorf("eliminar tags anteriores: %w", err)
}
if err := r.syncTags(ctx, tx, note.ID, note.UserID, note.Tags); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) GetByID(ctx context.Context, noteID, userID uuid.UUID) (*domain.Note, error) {
note := &domain.Note{}
var id, uid string
var pinned int
err := r.db.QueryRowContext(ctx,
`SELECT id, user_id, title, content, pinned, word_count, created_at, updated_at
FROM notes WHERE id=? AND user_id=?`,
noteID.String(), userID.String(),
).Scan(&id, &uid, ¬e.Title, ¬e.Content, &pinned, ¬e.WordCount, ¬e.CreatedAt, ¬e.UpdatedAt)
if err == sql.ErrNoRows {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("consultar nota: %w", err)
}
note.ID, _ = uuid.Parse(id)
note.UserID, _ = uuid.Parse(uid)
note.Pinned = pinned == 1
tags, err := r.getNoteTags(ctx, note.ID)
if err != nil {
return nil, err
}
note.Tags = tags
return note, nil
}
func (r *Repository) List(ctx context.Context, userID uuid.UUID, tagFilter string) ([]domain.Note, error) {
query := `
SELECT DISTINCT n.id, n.user_id, n.title, n.content, n.pinned, n.word_count, n.created_at, n.updated_at
FROM notes n`
args := []any{userID.String()}
if tagFilter != "" {
query += `
JOIN note_tags nt ON n.id = nt.note_id
JOIN tags t ON nt.tag_id = t.id AND t.name = ?`
args = append([]any{tagFilter}, args...)
}
query += ` WHERE n.user_id = ? ORDER BY n.pinned DESC, n.updated_at DESC`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listar notas: %w", err)
}
defer rows.Close()
var notes []domain.Note
for rows.Next() {
var note domain.Note
var id, uid string
var pinned int
if err := rows.Scan(&id, &uid, ¬e.Title, ¬e.Content, &pinned, ¬e.WordCount, ¬e.CreatedAt, ¬e.UpdatedAt); err != nil {
return nil, fmt.Errorf("escanear nota: %w", err)
}
note.ID, _ = uuid.Parse(id)
note.UserID, _ = uuid.Parse(uid)
note.Pinned = pinned == 1
notes = append(notes, note)
}
if len(notes) > 0 {
if err := r.loadTagsForNotes(ctx, notes); err != nil {
return nil, err
}
}
return notes, nil
}
func (r *Repository) Search(ctx context.Context, userID uuid.UUID, query string) ([]domain.Note, error) {
safeQuery := strings.ReplaceAll(query, `"`, `""`)
ftsQuery := fmt.Sprintf(`"%s"*`, safeQuery)
rows, err := r.db.QueryContext(ctx,
`SELECT n.id, n.user_id, n.title, n.content, n.pinned, n.word_count, n.created_at, n.updated_at,
highlight(notes_fts, 0, '<mark>', '</mark>') as title_hl,
snippet(notes_fts, 1, '<mark>', '</mark>', '...', 20) as snippet
FROM notes_fts
JOIN notes n ON notes_fts.rowid = n.rowid
WHERE notes_fts MATCH ? AND n.user_id = ?
ORDER BY rank
LIMIT 20`,
ftsQuery, userID.String(),
)
if err != nil {
return nil, fmt.Errorf("buscar notas: %w", err)
}
defer rows.Close()
var notes []domain.Note
for rows.Next() {
var note domain.Note
var id, uid string
var pinned int
var titleHL, snippet string
if err := rows.Scan(&id, &uid, ¬e.Title, ¬e.Content, &pinned, ¬e.WordCount,
¬e.CreatedAt, ¬e.UpdatedAt, &titleHL, &snippet); err != nil {
return nil, fmt.Errorf("escanear resultado: %w", err)
}
note.ID, _ = uuid.Parse(id)
note.UserID, _ = uuid.Parse(uid)
note.Pinned = pinned == 1
note.HTML = fmt.Sprintf(`<span class="font-medium">%s</span><p class="text-sm text-gray-500 mt-1">%s</p>`, titleHL, snippet)
notes = append(notes, note)
}
return notes, nil
}
func (r *Repository) TogglePin(ctx context.Context, noteID, userID uuid.UUID) error {
_, err := r.db.ExecContext(ctx,
`UPDATE notes SET pinned = NOT pinned, updated_at = datetime('now')
WHERE id = ? AND user_id = ?`,
noteID.String(), userID.String(),
)
return err
}
func (r *Repository) Delete(ctx context.Context, noteID, userID uuid.UUID) error {
res, err := r.db.ExecContext(ctx,
`DELETE FROM notes WHERE id = ? AND user_id = ?`,
noteID.String(), userID.String(),
)
if err != nil {
return fmt.Errorf("eliminar nota: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *Repository) GetUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT t.id, t.name, t.color, COUNT(nt.note_id) as count
FROM tags t
LEFT JOIN note_tags nt ON t.id = nt.tag_id
WHERE t.user_id = ?
GROUP BY t.id
ORDER BY count DESC, t.name`,
userID.String(),
)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []domain.Tag
for rows.Next() {
var tag domain.Tag
var id string
if err := rows.Scan(&id, &tag.Name, &tag.Color, &tag.Count); err != nil {
return nil, err
}
tag.ID, _ = uuid.Parse(id)
tags = append(tags, tag)
}
return tags, nil
}
func (r *Repository) syncTags(ctx context.Context, tx *sql.Tx, noteID, userID uuid.UUID, tags []domain.Tag) error {
for _, tag := range tags {
tagID := uuid.New()
_, err := tx.ExecContext(ctx,
`INSERT INTO tags (id, user_id, name, color) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, name) DO UPDATE SET color=excluded.color`,
tagID.String(), userID.String(), tag.Name, tag.Color,
)
if err != nil {
return fmt.Errorf("upsert tag %s: %w", tag.Name, err)
}
var actualID string
err = tx.QueryRowContext(ctx,
`SELECT id FROM tags WHERE user_id=? AND name=?`,
userID.String(), tag.Name,
).Scan(&actualID)
if err != nil {
return fmt.Errorf("obtener id de tag: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)`,
noteID.String(), actualID,
)
if err != nil {
return fmt.Errorf("vincular tag: %w", err)
}
}
return nil
}
func (r *Repository) getNoteTags(ctx context.Context, noteID uuid.UUID) ([]domain.Tag, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT t.id, t.name, t.color FROM tags t
JOIN note_tags nt ON t.id = nt.tag_id
WHERE nt.note_id = ? ORDER BY t.name`,
noteID.String(),
)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []domain.Tag
for rows.Next() {
var tag domain.Tag
var id string
if err := rows.Scan(&id, &tag.Name, &tag.Color); err != nil {
return nil, err
}
tag.ID, _ = uuid.Parse(id)
tags = append(tags, tag)
}
return tags, nil
}
func (r *Repository) loadTagsForNotes(ctx context.Context, notes []domain.Note) error {
ids := make([]string, len(notes))
for i, n := range notes {
ids[i] = "'" + n.ID.String() + "'"
}
rows, err := r.db.QueryContext(ctx,
fmt.Sprintf(
`SELECT nt.note_id, t.id, t.name, t.color FROM tags t
JOIN note_tags nt ON t.id = nt.tag_id
WHERE nt.note_id IN (%s) ORDER BY t.name`,
strings.Join(ids, ","),
),
)
if err != nil {
return err
}
defer rows.Close()
tagMap := make(map[string][]domain.Tag)
for rows.Next() {
var noteID, tagID, name, color string
if err := rows.Scan(¬eID, &tagID, &name, &color); err != nil {
return err
}
id, _ := uuid.Parse(tagID)
tagMap[noteID] = append(tagMap[noteID], domain.Tag{ID: id, Name: name, Color: color})
}
for i := range notes {
notes[i].Tags = tagMap[notes[i].ID.String()]
}
return nil
}
El método Search usa las funciones highlight() y snippet() de FTS5 para devolver resultados con los términos coincidentes ya envueltos en etiquetas <mark>. La aplicación no necesita post-procesar el texto — SQLite lo maneja.
Servicio de Notas: Renderizado Markdown
La capa de servicio posee el renderizado markdown. Cuando se obtiene una nota, el servicio convierte la fuente markdown almacenada en HTML saneado antes de que el template lo renderice.
// internal/note/service.go
package note
import (
"bytes"
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"noteflow/internal/domain"
)
var md = goldmark.New(
goldmark.WithExtensions(
extension.GFM, // GitHub Flavored Markdown: tablas, tachado, listas de tareas
extension.Typographer, // Comillas inteligentes, guiones
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(), // Links de anclaje en encabezados
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) CreateNote(ctx context.Context, userID uuid.UUID, title, content string, tagNames []string) (*domain.Note, error) {
tags := normalizeTagNames(tagNames)
note := &domain.Note{
ID: uuid.New(),
UserID: userID,
Title: strings.TrimSpace(title),
Content: content,
Tags: tags,
WordCount: countWords(content),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.repo.Create(ctx, note); err != nil {
return nil, err
}
note.HTML = RenderMarkdown(note.Content)
return note, nil
}
func (s *Service) GetNote(ctx context.Context, noteID, userID uuid.UUID) (*domain.Note, error) {
note, err := s.repo.GetByID(ctx, noteID, userID)
if err != nil {
return nil, err
}
note.HTML = RenderMarkdown(note.Content)
return note, nil
}
func (s *Service) ListNotes(ctx context.Context, userID uuid.UUID, tagFilter string) ([]domain.Note, error) {
return s.repo.List(ctx, userID, tagFilter)
}
func (s *Service) UpdateNote(ctx context.Context, noteID, userID uuid.UUID, title, content string, tagNames []string, pinned bool) (*domain.Note, error) {
note := &domain.Note{
ID: noteID,
UserID: userID,
Title: strings.TrimSpace(title),
Content: content,
Tags: normalizeTagNames(tagNames),
Pinned: pinned,
WordCount: countWords(content),
UpdatedAt: time.Now(),
}
if err := s.repo.Update(ctx, note); err != nil {
return nil, err
}
note.HTML = RenderMarkdown(note.Content)
return note, nil
}
func (s *Service) GetUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) {
return s.repo.GetUserTags(ctx, userID)
}
func (s *Service) TogglePin(ctx context.Context, noteID, userID uuid.UUID) error {
return s.repo.TogglePin(ctx, noteID, userID)
}
func (s *Service) Delete(ctx context.Context, noteID, userID uuid.UUID) error {
return s.repo.Delete(ctx, noteID, userID)
}
func (s *Service) SearchNotes(ctx context.Context, userID uuid.UUID, query string) ([]domain.Note, error) {
return s.repo.Search(ctx, userID, query)
}
func RenderMarkdown(source string) string {
var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil {
return "<p>Error al renderizar contenido</p>"
}
return buf.String()
}
func countWords(s string) int {
return len(strings.Fields(s))
}
func normalizeTagNames(names []string) []domain.Tag {
colors := []string{"blue", "green", "purple", "orange", "pink", "teal", "red", "yellow"}
var tags []domain.Tag
seen := make(map[string]bool)
for i, name := range names {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" || seen[name] {
continue
}
seen[name] = true
tags = append(tags, domain.Tag{
Name: name,
Color: colors[i%len(colors)],
})
}
return tags
}
Goldmark renderiza GitHub Flavored Markdown. Los usuarios obtienen tablas, tachado, listas de tareas (checkboxes en markdown renderizado) y code fencing con clases de sintaxis. La opción WithAutoHeadingID agrega links de anclaje a los encabezados para que los usuarios puedan enlazar a secciones dentro de una nota.
Templates Templ
Layout Base con Barra Lateral
// internal/view/layout.templ
package view
import "noteflow/internal/domain"
templ Layout(title string, user *domain.User, tags []domain.Tag) {
<!DOCTYPE html>
<html lang="es" class="h-full"
x-data="{ dark: localStorage.getItem('dark') === 'true', sidebarOpen: true }"
x-init="$watch('dark', v => { localStorage.setItem('dark', v); document.documentElement.classList.toggle('dark', v) }); document.documentElement.classList.toggle('dark', dark)"
:class="{ 'dark': dark }">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title } — Noteflow</title>
<link rel="stylesheet" href="/static/css/output.css"/>
<script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="h-full flex bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<!-- Barra lateral -->
<aside :class="sidebarOpen ? 'w-64' : 'w-14'"
class="flex-shrink-0 h-screen bg-white dark:bg-gray-900 border-r border-gray-200
dark:border-gray-800 flex flex-col transition-all duration-200 overflow-hidden">
<div class="h-14 flex items-center px-4 border-b border-gray-100 dark:border-gray-800 gap-3">
<button @click="sidebarOpen = !sidebarOpen"
class="w-6 h-6 flex flex-col gap-1.5 justify-center cursor-pointer">
<span class="block h-0.5 bg-current"></span>
<span class="block h-0.5 bg-current"></span>
<span class="block h-0.5 bg-current"></span>
</button>
<span x-show="sidebarOpen" class="font-bold text-blue-600 dark:text-blue-400 text-lg">
Noteflow
</span>
</div>
<nav class="flex-1 px-2 py-4 space-y-1">
@SidebarLink("/notes", "Notas", "note", title == "Notas")
@SidebarLink("/todos", "Tareas", "todo", title == "Tareas")
@SidebarLink("/search", "Buscar", "search", title == "Búsqueda")
</nav>
if len(tags) > 0 {
<div x-show="sidebarOpen" class="px-3 pb-4">
<p class="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">
Tags
</p>
for _, tag := range tags {
<a href={ templ.SafeURL("/notes?tag=" + tag.Name) }
class="flex items-center justify-between py-1 px-2 rounded text-sm
hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<span class="flex items-center gap-2">
<span class={ "w-2 h-2 rounded-full bg-" + tag.Color + "-400" }></span>
{ tag.Name }
</span>
<span class="text-xs text-gray-400">{ tag.Count }</span>
</a>
}
</div>
}
<div class="h-14 border-t border-gray-100 dark:border-gray-800 px-3 flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center
text-sm font-bold flex-shrink-0">
{ string([]rune(user.DisplayName)[0:1]) }
</div>
<span x-show="sidebarOpen" class="text-sm truncate flex-1">{ user.DisplayName }</span>
<button x-show="sidebarOpen" @click="dark = !dark"
class="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-xs">
<span x-show="!dark">Oscuro</span>
<span x-show="dark">Claro</span>
</button>
</div>
</aside>
<!-- Contenido principal -->
<div class="flex-1 flex flex-col min-w-0 h-screen overflow-hidden">
<header class="h-14 flex items-center px-6 border-b border-gray-200 dark:border-gray-800
bg-white dark:bg-gray-900 gap-4 flex-shrink-0">
<h1 class="font-semibold text-lg flex-1">{ title }</h1>
<form hx-get="/search" hx-target="#content" hx-trigger="input changed delay:300ms"
class="relative">
<input type="text" name="q" placeholder="Buscar..."
class="pl-8 pr-4 py-1.5 text-sm border border-gray-200 dark:border-gray-700
rounded-lg bg-gray-50 dark:bg-gray-800 focus:ring-2 focus:ring-blue-500
outline-none w-56"/>
</form>
<form hx-post="/logout">
<button type="submit"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400">
Salir
</button>
</form>
</header>
<main id="content" class="flex-1 overflow-y-auto p-6">
{ children... }
</main>
</div>
</body>
</html>
}
templ SidebarLink(href, label, icon string, active bool) {
<a href={ templ.SafeURL(href) }
class={ "flex items-center gap-3 px-2 py-2 rounded-lg text-sm transition-colors " +
sidebarLinkClass(active) }>
<span class="w-5 h-5 flex-shrink-0 text-center">{ sidebarIcon(icon) }</span>
<span x-show="sidebarOpen">{ label }</span>
</a>
}
func sidebarLinkClass(active bool) string {
if active {
return "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 font-medium"
}
return "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
}
func sidebarIcon(icon string) string {
switch icon {
case "note":
return "N"
case "todo":
return "T"
case "search":
return "B"
default:
return "?"
}
}
Vista de Lista de Notas
// internal/view/note/list.templ
package note
import (
"fmt"
"noteflow/internal/domain"
"noteflow/internal/view"
)
templ ListPage(user *domain.User, notes []domain.Note, tags []domain.Tag, activeTag string) {
@view.Layout("Notas", user, tags) {
<div class="max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
if activeTag != "" {
<span class="text-sm text-gray-500">Filtrado por:</span>
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700
dark:text-blue-400 rounded-full text-sm font-medium">
{ activeTag }
</span>
<a href="/notes" class="text-xs text-gray-400 hover:text-gray-600">Limpiar</a>
}
<span class="text-sm text-gray-400">
{ fmt.Sprintf("%d notas", len(notes)) }
</span>
</div>
<a href="/notes/new"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
Nueva Nota
</a>
</div>
if len(notes) == 0 {
@view.EmptyState("Sin notas aún", "Crea tu primera nota para comenzar.")
} else {
@pinnedSection(notes)
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, n := range notes {
if !n.Pinned {
@NoteCard(n)
}
}
</div>
}
</div>
}
}
templ pinnedSection(notes []domain.Note) {
for _, n := range notes {
if n.Pinned {
<div class="mb-6">
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3">Fijadas</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, pinned := range notes {
if pinned.Pinned {
@NoteCard(pinned)
}
}
</div>
</div>
break
}
}
}
templ NoteCard(n domain.Note) {
<div class="group bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800
p-4 hover:shadow-md dark:hover:shadow-black/30 transition-shadow relative">
if n.Pinned {
<span class="absolute top-3 right-3 text-amber-400 text-xs">fijada</span>
}
<a href={ templ.SafeURL("/notes/" + n.ID.String()) } class="block">
<h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate pr-6">
{ n.Title }
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-3 leading-relaxed">
{ n.Excerpt(160) }
</p>
</a>
if len(n.Tags) > 0 {
<div class="flex flex-wrap gap-1.5 mt-3">
for _, tag := range n.Tags {
@TagBadge(tag)
}
</div>
}
<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
<span class="text-xs text-gray-400">
{ fmt.Sprintf("%d palabras", n.WordCount) }
</span>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button hx-post={ "/notes/" + n.ID.String() + "/pin" }
hx-target="closest div.group"
hx-swap="outerHTML"
class="text-xs text-gray-400 hover:text-amber-500">
{ pinLabel(n.Pinned) }
</button>
<a href={ templ.SafeURL("/notes/" + n.ID.String() + "/edit") }
class="text-xs text-gray-400 hover:text-blue-600">Editar</a>
<button hx-delete={ "/notes/" + n.ID.String() }
hx-confirm="¿Eliminar esta nota?"
hx-target="closest div.group"
hx-swap="outerHTML swap:0.2s"
class="text-xs text-gray-400 hover:text-red-500">
Eliminar
</button>
</div>
</div>
</div>
}
templ TagBadge(tag domain.Tag) {
<span class={ "px-2 py-0.5 rounded-full text-xs font-medium bg-" + tag.Color + "-100 text-" +
tag.Color + "-700 dark:bg-" + tag.Color + "-900/30 dark:text-" + tag.Color + "-400" }>
{ tag.Name }
</span>
}
func pinLabel(pinned bool) string {
if pinned {
return "Desfijar"
}
return "Fijar"
}
Vista de Lista de Tareas con HTMX Checklist
// internal/view/todo/list.templ
package todo
import (
"fmt"
"noteflow/internal/domain"
"noteflow/internal/view"
"github.com/google/uuid"
)
templ ListPage(user *domain.User, lists []domain.TodoList, tags []domain.Tag) {
@view.Layout("Tareas", user, tags) {
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<span class="text-sm text-gray-400">{ fmt.Sprintf("%d listas", len(lists)) }</span>
<a href="/todos/new"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
Nueva Lista
</a>
</div>
if len(lists) == 0 {
@view.EmptyState("Sin listas de tareas", "Crea tu primera lista para organizar tus tareas.")
} else {
<div class="space-y-4">
for _, list := range lists {
@TodoListCard(list)
}
</div>
}
</div>
}
}
templ TodoListCard(list domain.TodoList) {
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5"
id={ "list-" + list.ID.String() }>
<div class="flex items-start justify-between mb-4">
<div>
<a href={ templ.SafeURL("/todos/" + list.ID.String()) }
class="font-semibold text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400">
{ list.Title }
</a>
if list.Description != "" {
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{ list.Description }</p>
}
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-4">
if list.Priority != domain.ListPriorityNone {
<span class={ "text-xs px-2 py-0.5 rounded-full " + list.Priority.BadgeClass() }>
{ list.Priority.Label() }
</span>
}
if list.IsOverdue() {
<span class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
Vencida
</span>
}
</div>
</div>
if len(list.Items) > 0 {
<div class="mb-4">
<div class="flex items-center justify-between text-xs text-gray-400 mb-1">
<span>{ fmt.Sprintf("%d / %d completadas", list.DoneCount(), len(list.Items)) }</span>
<span>{ fmt.Sprintf("%d%%", list.Progress()) }</span>
</div>
<div class="h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div class={ progressBarClass(list.Progress()) }
style={ fmt.Sprintf("width: %d%%", list.Progress()) }></div>
</div>
</div>
}
<div class="space-y-2">
for i, item := range list.Items {
if i < 3 {
@TodoItemRow(item, list.ID)
}
}
if len(list.Items) > 3 {
<a href={ templ.SafeURL("/todos/" + list.ID.String()) }
class="block text-xs text-gray-400 hover:text-blue-600 pt-1">
{ fmt.Sprintf("+ %d más", len(list.Items) - 3) }
</a>
}
</div>
<form hx-post={ "/todos/" + list.ID.String() + "/items" }
hx-target={ "#list-" + list.ID.String() + " .space-y-2" }
hx-swap="beforeend"
@submit="this.reset()"
class="mt-4 flex gap-2">
<input type="text" name="text" placeholder="Agregar tarea..."
required
class="flex-1 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700
rounded-lg bg-gray-50 dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"/>
<button type="submit"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg">
Agregar
</button>
</form>
</div>
}
templ TodoItemRow(item domain.TodoItem, listID uuid.UUID) {
<div class="flex items-start gap-3 group" id={ "item-" + item.ID.String() }>
<button hx-post={ "/todos/items/" + item.ID.String() + "/toggle" }
hx-target={ "#item-" + item.ID.String() }
hx-swap="outerHTML"
class={ "w-4 h-4 mt-0.5 rounded border-2 flex-shrink-0 transition-colors " + checkboxClass(item.Done) }>
</button>
<span class={ "text-sm flex-1 " + itemTextClass(item.Done) }>
{ item.Text }
</span>
<button hx-delete={ "/todos/items/" + item.ID.String() }
hx-target={ "#item-" + item.ID.String() }
hx-swap="outerHTML swap:0.15s"
class="text-xs text-gray-300 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity">
x
</button>
</div>
}
func checkboxClass(done bool) string {
if done {
return "border-blue-600 bg-blue-600 text-white"
}
return "border-gray-300 dark:border-gray-600 hover:border-blue-500"
}
func itemTextClass(done bool) string {
if done {
return "line-through text-gray-400 dark:text-gray-500"
}
return "text-gray-700 dark:text-gray-300"
}
func progressBarClass(progress int) string {
if progress == 100 {
return "h-full bg-green-500 rounded-full transition-all"
}
if progress >= 50 {
return "h-full bg-blue-500 rounded-full transition-all"
}
return "h-full bg-gray-400 rounded-full transition-all"
}
La interacción del checklist es el ejemplo más limpio del modelo HTMX. El botón checkbox envía un POST a /todos/items/{id}/toggle. El servidor alterna el item en la base de datos, renderiza el parcial TodoItemRow con el nuevo estado y lo devuelve. HTMX reemplaza la fila existente con el HTML fresco. La actualización de UI y la actualización de base de datos ocurren en el mismo round-trip, sin gestión de estado en JavaScript.
Handler de Búsqueda
// internal/handler/search.go
package handler
import (
"net/http"
"noteflow/internal/auth"
"noteflow/internal/note"
"noteflow/internal/todo"
viewsearch "noteflow/internal/view/search"
)
type SearchHandler struct {
noteService *note.Service
todoService *todo.Service
}
func NewSearchHandler(noteService *note.Service, todoService *todo.Service) *SearchHandler {
return &SearchHandler{noteService: noteService, todoService: todoService}
}
func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
query := r.URL.Query().Get("q")
if query == "" {
w.WriteHeader(http.StatusOK)
return
}
notes, _ := h.noteService.SearchNotes(r.Context(), user.ID, query)
todos, _ := h.todoService.SearchLists(r.Context(), user.ID, query)
// Si la petición es de HTMX, devolver solo el fragmento de resultados
if r.Header.Get("HX-Request") == "true" {
viewsearch.ResultsFragment(notes, todos, query).Render(r.Context(), w)
return
}
// Página completa para navegación directa
tags, _ := h.noteService.GetUserTags(r.Context(), user.ID)
viewsearch.SearchPage(user, tags, notes, todos, query).Render(r.Context(), w)
}
La cabecera HX-Request la establece HTMX en cada petición que hace. El handler de búsqueda verifica esta cabecera para decidir si renderiza una página completa (para navegación directa) o solo el fragmento de resultados (para búsquedas disparadas por HTMX). Este es el patrón estándar de mejora progresiva: el mismo endpoint sirve tanto actualizaciones parciales HTMX como navegación directa del navegador.
Router Principal
// cmd/server/main.go
package main
import (
"context"
"database/sql"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "modernc.org/sqlite"
"noteflow/internal/auth"
"noteflow/internal/config"
"noteflow/internal/db"
"noteflow/internal/handler"
"noteflow/internal/note"
"noteflow/internal/todo"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
slog.Error("error de configuración", "err", err)
os.Exit(1)
}
database, err := db.Open(cfg.DatabasePath)
if err != nil {
slog.Error("error de base de datos", "err", err)
os.Exit(1)
}
defer database.Close()
if err := runMigrations(database); err != nil {
slog.Error("error de migración", "err", err)
os.Exit(1)
}
slog.Info("base de datos lista", "path", cfg.DatabasePath)
authRepo := auth.NewRepository(database)
authService := auth.NewService(authRepo)
noteRepo := note.NewRepository(database)
noteService := note.NewService(noteRepo)
todoRepo := todo.NewRepository(database)
todoService := todo.NewService(todoRepo)
authHandler := handler.NewAuthHandler(authService)
noteHandler := handler.NewNoteHandler(noteService)
todoHandler := handler.NewTodoHandler(todoService)
searchHandler := handler.NewSearchHandler(noteService, todoService)
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
r.Use(middleware.Timeout(15 * time.Second))
r.Use(securityHeaders)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Rutas públicas
r.Get("/login", authHandler.ShowLogin)
r.Post("/login", authHandler.Login)
r.Get("/register", authHandler.ShowRegister)
r.Post("/register", authHandler.Register)
// Rutas protegidas
r.Group(func(r chi.Router) {
r.Use(authService.Middleware)
r.Post("/logout", authHandler.Logout)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/notes", http.StatusSeeOther)
})
// Notas
r.Get("/notes", noteHandler.List)
r.Get("/notes/new", noteHandler.ShowNew)
r.Post("/notes", noteHandler.Create)
r.Get("/notes/{noteID}", noteHandler.Show)
r.Get("/notes/{noteID}/edit", noteHandler.ShowEdit)
r.Post("/notes/{noteID}", noteHandler.Update)
r.Delete("/notes/{noteID}", noteHandler.Delete)
r.Post("/notes/{noteID}/pin", noteHandler.TogglePin)
r.Post("/notes/preview", noteHandler.Preview)
// Tareas
r.Get("/todos", todoHandler.List)
r.Get("/todos/new", todoHandler.ShowNew)
r.Post("/todos", todoHandler.Create)
r.Get("/todos/{listID}", todoHandler.ShowDetail)
r.Post("/todos/{listID}/items", todoHandler.AddItem)
r.Post("/todos/items/{itemID}/toggle", todoHandler.ToggleItem)
r.Delete("/todos/items/{itemID}", todoHandler.DeleteItem)
// Búsqueda
r.Get("/search", searchHandler.Search)
})
srv := &http.Server{
Addr: cfg.Addr(),
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
go func() {
slog.Info("servidor iniciando", "addr", cfg.Addr())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("error del servidor", "err", err)
os.Exit(1)
}
}()
<-done
slog.Info("apagando servidor")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
slog.Info("detenido")
}
func runMigrations(database *sql.DB) error {
driver, err := sqlite.WithInstance(database, &sqlite.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithDatabaseInstance("file://migrations", "sqlite", driver)
if err != nil {
return err
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}
return nil
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
Pruebas
// internal/domain/todo_test.go
package domain
import (
"testing"
"time"
)
func TestTodoList_Progress(t *testing.T) {
tests := []struct {
name string
items []TodoItem
expected int
}{
{
name: "lista vacía devuelve 0",
items: []TodoItem{},
expected: 0,
},
{
name: "todas completadas devuelve 100",
items: []TodoItem{{Done: true}, {Done: true}},
expected: 100,
},
{
name: "la mitad completada devuelve 50",
items: []TodoItem{{Done: true}, {Done: false}},
expected: 50,
},
{
name: "una de tres completada devuelve 33",
items: []TodoItem{{Done: true}, {Done: false}, {Done: false}},
expected: 33,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
list := &TodoList{Items: tt.items}
if got := list.Progress(); got != tt.expected {
t.Errorf("Progress() = %d, quería %d", got, tt.expected)
}
})
}
}
func TestTodoList_IsOverdue(t *testing.T) {
pasado := time.Now().Add(-24 * time.Hour)
futuro := time.Now().Add(24 * time.Hour)
tests := []struct {
name string
dueDate *time.Time
done bool
expected bool
}{
{"sin fecha límite nunca está vencida", nil, false, false},
{"fecha pasada con pendientes está vencida", &pasado, false, true},
{"fecha pasada pero 100% completa no está vencida", &pasado, true, false},
{"fecha futura no está vencida", &futuro, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var items []TodoItem
if tt.done {
items = []TodoItem{{Done: true}}
} else {
items = []TodoItem{{Done: false}}
}
list := &TodoList{DueDate: tt.dueDate, Items: items}
if got := list.IsOverdue(); got != tt.expected {
t.Errorf("IsOverdue() = %v, quería %v", got, tt.expected)
}
})
}
}
// internal/note/service_test.go
package note
import (
"testing"
)
func TestCountWords(t *testing.T) {
tests := []struct {
input string
expected int
}{
{"", 0},
{"hola mundo", 2},
{" muchos espacios ", 2},
{"uno", 1},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := countWords(tt.input); got != tt.expected {
t.Errorf("countWords(%q) = %d, quería %d", tt.input, got, tt.expected)
}
})
}
}
func TestNormalizeTagNames(t *testing.T) {
names := []string{"Go", " golang ", "GO", "backend"}
tags := normalizeTagNames(names)
// "go" y "golang" son diferentes, "GO" duplica "go"
// Resultado esperado: "go", "golang", "backend" (3 únicos)
if len(tags) != 3 {
t.Errorf("esperaba 3 tags únicos, obtuve %d", len(tags))
}
}
Dockerfile
FROM golang:1.25-alpine AS builder
WORKDIR /app
RUN go install github.com/a-h/templ/cmd/templ@latest
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN templ generate
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /noteflow ./cmd/server
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /noteflow .
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/static ./static
VOLUME ["/app/data"]
ENV DATABASE_PATH=/app/data/noteflow.db
EXPOSE 8080
ENTRYPOINT ["./noteflow"]
El volumen de base de datos en /app/data persiste el archivo SQLite entre reinicios del contenedor. Esta es toda la capa de persistencia — un archivo, un montaje de volumen.
go.mod
module noteflow
go 1.25
require (
github.com/a-h/templ v0.3.906
github.com/caarlos0/env/v11 v11.3.1
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.18.3
github.com/google/uuid v1.6.0
github.com/yuin/goldmark v1.7.12
golang.org/x/crypto v0.38.0
modernc.org/sqlite v1.37.1
)
Makefile
.PHONY: dev build test templ tailwind migrate-up migrate-down clean setup
dev:
air
build: templ tailwind
go build -ldflags="-s -w" -o bin/noteflow ./cmd/server
test:
go test ./... -v -race -count=1
templ:
templ generate
tailwind:
npx tailwindcss -i static/css/input.css -o static/css/output.css --minify
tailwind-watch:
npx tailwindcss -i static/css/input.css -o static/css/output.css --watch
migrate-up:
go run ./cmd/migrate up
migrate-down:
go run ./cmd/migrate down 1
clean:
rm -f bin/noteflow noteflow.db
rm -f static/css/output.css
find internal/view -name "*_templ.go" -delete
setup:
go install github.com/a-h/templ/cmd/templ@latest
go install github.com/air-verse/air@latest
npm install
cp .env.example .env
La Experiencia de Uso
El panel de vista previa de markdown en el editor de notas se actualiza mientras escribes. No hay debounce visible — HTMX agrupa las pulsaciones (retraso de 500ms) y envía una petición. El servidor renderiza el markdown y devuelve el fragmento HTML. El panel de vista previa se intercambia. Para el usuario, se siente como una vista previa en vivo construida con una librería de editor JavaScript compleja. Para el desarrollador, son quince líneas de atributos HTMX y un endpoint Go.
Esta es la ventaja de productividad del full stack Go: las funciones que parecen complejas de construir son a menudo simples cuando el servidor posee el renderizado. La vista previa de markdown, la búsqueda instantánea, el toggle del checkbox, el badge de nota fijada — cada interacción es una petición HTTP, una consulta a la base de datos y un render Templ. Sin sincronización de estado, sin serialización, sin modelo de datos en el cliente que mantener sincronizado con el servidor.
Una base de código que cabe en la cabeza de una persona se envía más rápido que una que requiere un equipo para traducir entre capas. El full stack Go no es un compromiso — es una elección para optimizar lo que realmente importa: construir algo útil y mantenerlo funcionando.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.