Proyecto Todo List: Estructura y Modelo de Datos
Hora de poner en practica todo lo aprendido. Vamos a construir una Todo List completa con Go + Templ + HTMX. En este capitulo definimos la arquitectura, los modelos de datos y la base del proyecto.
Arquitectura del Proyecto
Organizamos el codigo en packages con responsabilidades claras:
todo-app/
├── main.go # Punto de entrada, rutas y servidor
├── go.mod
├── go.sum
├── models/
│ └── todo.go # Struct Todo y Store
├── handlers/
│ └── todo.go # Handlers HTTP
├── views/
│ ├── layout.templ # Layout base
│ ├── todo_list.templ # Lista de todos
│ ├── todo_item.templ # Item individual
│ └── todo_form.templ # Formulario de creacion
└── static/
└── styles.css # Estilos CSS
| Package | Responsabilidad |
|---|---|
models | Estructuras de datos y logica de almacenamiento |
handlers | Recibir HTTP requests y devolver respuestas |
views | Templates Templ (la UI) |
static | Archivos estaticos (CSS) |
Esta separacion sigue el principio de responsabilidad unica: cada package hace una sola cosa.
Inicializar el Proyecto
mkdir todo-app && cd todo-app
go mod init todo-app
go get github.com/a-h/templ
Modelo de Datos
El struct Todo representa una tarea. Lo definimos en models/todo.go:
package models
import "time"
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
}
Cuatro campos son suficientes. ID identifica cada tarea, Completed controla su estado y CreatedAt registra cuando se creo.
Store en Memoria
Para este proyecto usamos un slice en memoria protegido con sync.RWMutex para manejar concurrencia. En una app real usarias una base de datos.
package models
import (
"sync"
"sync/atomic"
"time"
)
type TodoStore struct {
mu sync.RWMutex
todos []Todo
nextID atomic.Int64
}
func NewTodoStore() *TodoStore {
return &TodoStore{}
}
El atomic.Int64 genera IDs unicos sin necesidad de un mutex adicional. Cada vez que agregamos un todo, incrementamos el contador.
Metodos del Store
Obtener Todos
func (s *TodoStore) GetAll() []Todo {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Todo, len(s.todos))
copy(result, s.todos)
return result
}
Usamos RLock (read lock) porque solo leemos. Retornamos una copia para evitar data races.
Obtener por ID
func (s *TodoStore) GetByID(id int) (Todo, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, t := range s.todos {
if t.ID == id {
return t, true
}
}
return Todo{}, false
}
Agregar
func (s *TodoStore) Add(title string) Todo {
s.mu.Lock()
defer s.mu.Unlock()
todo := Todo{
ID: int(s.nextID.Add(1)),
Title: title,
Completed: false,
CreatedAt: time.Now(),
}
s.todos = append(s.todos, todo)
return todo
}
nextID.Add(1) incrementa atomicamente y devuelve el nuevo valor.
Toggle Completado
func (s *TodoStore) Toggle(id int) (Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, t := range s.todos {
if t.ID == id {
s.todos[i].Completed = !t.Completed
return s.todos[i], true
}
}
return Todo{}, false
}
Actualizar Titulo
func (s *TodoStore) Update(id int, title string) (Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, t := range s.todos {
if t.ID == id {
s.todos[i].Title = title
return s.todos[i], true
}
}
return Todo{}, false
}
Eliminar
func (s *TodoStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, t := range s.todos {
if t.ID == id {
s.todos = append(s.todos[:i], s.todos[i+1:]...)
return true
}
}
return false
}
main.go: Rutas y Servidor
El punto de entrada conecta todo: crea el store, registra las rutas y arranca el servidor.
package main
import (
"fmt"
"log"
"net/http"
"todo-app/handlers"
"todo-app/models"
)
func main() {
store := models.NewTodoStore()
h := handlers.NewTodoHandler(store)
mux := http.NewServeMux()
// Archivos estaticos
mux.Handle("GET /static/",
http.StripPrefix("/static/",
http.FileServer(http.Dir("static"))))
// Rutas de la app
mux.HandleFunc("GET /", h.Index)
mux.HandleFunc("GET /todos", h.List)
mux.HandleFunc("POST /todos", h.Create)
mux.HandleFunc("PUT /todos/{id}/toggle", h.Toggle)
mux.HandleFunc("PUT /todos/{id}", h.Update)
mux.HandleFunc("DELETE /todos/{id}", h.Delete)
fmt.Println("Servidor en http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Usamos el nuevo router de Go 1.22+ que soporta metodos HTTP y path parameters como {id}.
Layout Base en Templ
El layout envuelve todas las paginas con el HTML base. Creamos views/layout.templ:
package views
templ Layout(title string) {
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport"
content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/styles.css"/>
<script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
<main class="container">
<h1>Todo List</h1>
{ children... }
</main>
</body>
</html>
}
Puntos clave:
- HTMX se carga desde CDN (un solo script tag)
{ children... }permite inyectar contenido dentro del layout- El CSS se sirve como archivo estatico
Handler Base
Creamos la estructura del handler en handlers/todo.go:
package handlers
import "todo-app/models"
type TodoHandler struct {
store *models.TodoStore
}
func NewTodoHandler(store *models.TodoStore) *TodoHandler {
return &TodoHandler{store: store}
}
El handler recibe el store por inyeccion de dependencias. En los siguientes capitulos implementaremos cada metodo.
Generar Codigo Templ
Despues de crear archivos .templ, genera el codigo Go:
templ generate
Esto crea archivos _templ.go junto a cada .templ. Estos archivos generados son los que Go compila.
Resumen
| Elemento | Archivo | Funcion |
|---|---|---|
| Modelo | models/todo.go | Struct Todo y TodoStore |
| Handler | handlers/todo.go | Logica HTTP |
| Vistas | views/*.templ | UI con Templ |
| Entrada | main.go | Rutas y servidor |
La arquitectura es simple pero escalable. Cada pieza tiene una responsabilidad clara y es facil de testear de forma independiente.
<— Capitulo 8: HTMX Avanzado | Capitulo 10: Listar y Crear Tareas —>